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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
220 changes: 220 additions & 0 deletions src/MandoCode.Desktop.Tests/AgentCommandOutputTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System.Text.RegularExpressions;
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// 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.
/// </summary>
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';

/// <summary>Complete SGR colour sequences, removed so what remains can be checked for damage.</summary>
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<char>)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<bool>();
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<char>)StripSgr(log.Snapshot()));
}
}
4 changes: 3 additions & 1 deletion src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Expand Down Expand Up @@ -58,6 +58,8 @@
<Compile Include="..\MandoCode.Desktop\Services\TranscriptJournal.cs" Link="src\TranscriptJournal.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ModelNoticeReplay.cs" Link="src\ModelNoticeReplay.cs" />
<Compile Include="..\MandoCode.Desktop\Services\StableCollection.cs" Link="src\StableCollection.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentCommandFormat.cs" Link="src\AgentCommandFormat.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentCommandLog.cs" Link="src\AgentCommandLog.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
<!-- History's full-text matching + snippet extraction. Pure (text in, match out); the file reads
and caching live in ConversationTextCache, which is NOT compiled here because it touches
Expand Down
37 changes: 27 additions & 10 deletions src/MandoCode.Desktop/Assets/web/terminal/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
try { window.chrome.webview.postMessage(JSON.stringify(obj)); } catch (e) { }
}

// A blinking-less, invisible cursor: xterm still tracks a cursor position in a read-only
// buffer, and showing it would promise a text entry point that does not exist.
function readOnlyTheme(t) {
const base = makeTheme(t);
return Object.assign({}, base, { cursor: "transparent", cursorAccent: base.background });
}

function makeTheme(t) {
return t || {
background: "#0b0b12",
Expand All @@ -31,7 +38,9 @@
};
}

function create(id, cols, rows, theme) {
// readOnly backs the agent-output tab: same renderer, but nothing is sent back to C# and no
// cursor is drawn, because there is no process on the other end for a keystroke to reach.
function create(id, cols, rows, theme, readOnly) {
if (terms[id]) return;

const el = document.createElement("div");
Expand All @@ -47,20 +56,24 @@
fontFamily: "Cascadia Mono, Consolas, 'Courier New', monospace",
fontSize: 13,
lineHeight: 1.1,
theme: makeTheme(theme),
theme: readOnly ? readOnlyTheme(theme) : makeTheme(theme),
scrollback: 5000,
allowProposedApi: true
allowProposedApi: true,
disableStdin: !!readOnly
});

const fit = new FitAddonNS.FitAddon();
term.loadAddon(fit);
term.open(el);

// Keystrokes / pasted text -> C# -> shell stdin.
term.onData(d => post({ type: "data", id: id, data: d }));
term.onBinary(d => post({ type: "data", id: id, data: d }));
// Keystrokes / pasted text -> C# -> shell stdin. Never wired for a read-only terminal, so
// input is dropped here rather than travelling to C# to be ignored there.
if (!readOnly) {
term.onData(d => post({ type: "data", id: id, data: d }));
term.onBinary(d => post({ type: "data", id: id, data: d }));
}

terms[id] = { term: term, fit: fit, el: el };
terms[id] = { term: term, fit: fit, el: el, readOnly: !!readOnly };
}

function write(id, b64) {
Expand Down Expand Up @@ -89,7 +102,8 @@

function focus(id) {
const t = terms[id];
if (t) setTimeout(() => { try { t.term.focus(); } catch (e) { } }, 0);
if (!t || t.readOnly) return; // nothing to type into; leave the caret where the user put it
setTimeout(() => { try { t.term.focus(); } catch (e) { } }, 0);
}

function dispose(id) {
Expand Down Expand Up @@ -117,15 +131,18 @@
}

function setTheme(theme) {
for (const k in terms) terms[k].term.options.theme = makeTheme(theme);
for (const k in terms) {
terms[k].term.options.theme =
terms[k].readOnly ? readOnlyTheme(theme) : makeTheme(theme);
}
}

// C# -> JS (host.PostWebMessageAsJson -> parsed object on e.data).
window.chrome.webview.addEventListener("message", function (e) {
const m = e.data;
if (!m || !m.type) return;
switch (m.type) {
case "create": create(m.id, m.cols, m.rows, m.theme); break;
case "create": create(m.id, m.cols, m.rows, m.theme, m.readOnly); break;
case "write": write(m.id, m.data); break;
case "show": show(m.id); break;
case "fit": fit(m.id); break;
Expand Down
Loading
Loading