diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d8da5da..178d85d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,3 +22,6 @@ jobs:
- name: Build
run: dotnet build src/MandoCode.Desktop/MandoCode.Desktop.csproj -c Release
+
+ - name: Test
+ run: dotnet test src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj -c Release --nologo
diff --git a/src/MandoCode.Desktop.Tests/GitQuickStatusTests.cs b/src/MandoCode.Desktop.Tests/GitQuickStatusTests.cs
new file mode 100644
index 0000000..b38271b
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/GitQuickStatusTests.cs
@@ -0,0 +1,228 @@
+using System.Diagnostics;
+using MandoCode.Desktop.Services;
+using MandoCode.Models;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+/// Integration tests against real throwaway git repos — verifies the porcelain
+/// parsing, diff parsing, and undo behavior against whatever git actually outputs.
+public sealed class GitQuickStatusTests
+{
+ /// Throwaway git repo in the temp dir, deleted on dispose.
+ private sealed class TempRepo : IDisposable
+ {
+ public string Root { get; }
+
+ public TempRepo()
+ {
+ Root = Path.Combine(Path.GetTempPath(), "mandocode-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Root);
+ Git("init", "-b", "main");
+ Git("config", "user.email", "test@test.local");
+ Git("config", "user.name", "MandoCode Tests");
+ Git("config", "commit.gpgsign", "false");
+ // Byte-faithful checkouts: Git for Windows defaults to autocrlf=true, which
+ // would restore "x\n" as "x\r\n" and fail exact-content assertions.
+ Git("config", "core.autocrlf", "false");
+ }
+
+ public void Write(string rel, string content) =>
+ File.WriteAllText(Path.Combine(Root, rel), content);
+
+ public string Read(string rel) => File.ReadAllText(Path.Combine(Root, rel));
+
+ public void CommitAll(string message = "commit")
+ {
+ Git("add", "-A");
+ Git("commit", "-m", message);
+ }
+
+ public string Git(params string[] args)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = Root,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ using var p = Process.Start(psi)!;
+ var stdout = p.StandardOutput.ReadToEnd();
+ var stderr = p.StandardError.ReadToEnd();
+ p.WaitForExit();
+ if (p.ExitCode != 0)
+ throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}");
+ return stdout;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ // .git objects are read-only on Windows; strip attributes or Delete throws.
+ foreach (var f in Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories))
+ File.SetAttributes(f, FileAttributes.Normal);
+ Directory.Delete(Root, recursive: true);
+ }
+ catch { /* leftover temp dirs are tolerable; failing the test run is not */ }
+ }
+ }
+
+ [Fact]
+ public void NonRepo_ReturnsNull()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "mandocode-tests-plain-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ try
+ {
+ Assert.Null(GitQuickStatus.TryGet(dir));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CleanRepo_ReportsBranchAndCleanTree()
+ {
+ using var repo = new TempRepo();
+ repo.Write("a.txt", "one\n");
+ repo.CommitAll();
+
+ var info = GitQuickStatus.TryGet(repo.Root);
+ Assert.NotNull(info);
+ Assert.Equal("main", info!.Branch);
+ Assert.False(info.Dirty);
+ Assert.Empty(info.Changes);
+ Assert.Equal(40, info.Oid.Length); // full SHA-1 — the delta tracker compares these
+ }
+
+ [Fact]
+ public void ModifiedUntrackedAndDeleted_GetTheirKinds()
+ {
+ using var repo = new TempRepo();
+ repo.Write("mod.txt", "one\n");
+ repo.Write("del.txt", "bye\n");
+ repo.CommitAll();
+
+ repo.Write("mod.txt", "two\n");
+ repo.Write("new.txt", "hi\n");
+ File.Delete(Path.Combine(repo.Root, "del.txt"));
+
+ var info = GitQuickStatus.TryGet(repo.Root)!;
+ Assert.True(info.Dirty);
+ var kinds = info.Changes.ToDictionary(c => c.RelPath, c => c.Kind);
+ Assert.Equal("M", kinds["mod.txt"]);
+ Assert.Equal("U", kinds["new.txt"]);
+ Assert.Equal("D", kinds["del.txt"]);
+ }
+
+ // Git resolves repos by walking UP the tree (a tab on a repo subfolder, or a stray .git
+ // in Desktop/home catching everything beneath it). Results must be scoped to the queried
+ // subtree with subtree-relative paths — porcelain's repo-root-relative paths would
+ // otherwise break every downstream path join, undo pathspec, and @token.
+ [Fact]
+ public void SubfolderRoot_ScopesChangesToSubtree()
+ {
+ using var repo = new TempRepo();
+ Directory.CreateDirectory(Path.Combine(repo.Root, "sub"));
+ repo.Write("outside.txt", "o\n");
+ repo.Write(Path.Combine("sub", "inside.txt"), "i\n");
+ repo.CommitAll();
+ repo.Write("outside.txt", "o2\n");
+ repo.Write(Path.Combine("sub", "inside.txt"), "i2\n");
+
+ var info = GitQuickStatus.TryGet(Path.Combine(repo.Root, "sub"));
+ Assert.NotNull(info);
+ Assert.Equal("main", info!.Branch);
+ var entry = Assert.Single(info.Changes);
+ Assert.Equal("inside.txt", entry.RelPath); // subtree-relative, not "sub/inside.txt"
+ Assert.Equal("M", entry.Kind);
+ Assert.True(info.RepoRoot.Length > 0); // callers can disclose the ancestor repo
+ }
+
+ [Fact]
+ public void CommitMovesOid()
+ {
+ using var repo = new TempRepo();
+ repo.Write("a.txt", "one\n");
+ repo.CommitAll();
+ var before = GitQuickStatus.TryGet(repo.Root)!.Oid;
+
+ repo.Write("a.txt", "two\n");
+ repo.CommitAll("second");
+ var after = GitQuickStatus.TryGet(repo.Root)!.Oid;
+
+ Assert.NotEqual(before, after); // this is what distinguishes COMMITTED from REVERTED
+ }
+
+ [Fact]
+ public void Diff_ModifiedFile_ParsesAddsAndRemoves()
+ {
+ using var repo = new TempRepo();
+ repo.Write("a.txt", "one\n");
+ repo.CommitAll();
+ repo.Write("a.txt", "two\n");
+
+ var diff = GitQuickStatus.TryGetDiff(repo.Root, "a.txt", untracked: false);
+ Assert.NotNull(diff);
+ Assert.Contains(diff!.Lines, l => l.LineType == DiffLineType.Removed && l.Content == "one");
+ Assert.Contains(diff.Lines, l => l.LineType == DiffLineType.Added && l.Content == "two");
+ Assert.Equal("1 deletion(s), 1 addition(s)", diff.Summary);
+ }
+
+ [Fact]
+ public void Diff_UntrackedFile_IsAllAdditions()
+ {
+ using var repo = new TempRepo();
+ repo.Write("seed.txt", "x\n");
+ repo.CommitAll();
+ repo.Write("new.txt", "l1\nl2\nl3\n");
+
+ var diff = GitQuickStatus.TryGetDiff(repo.Root, "new.txt", untracked: true);
+ Assert.NotNull(diff);
+ Assert.Equal(3, diff!.Lines.Count);
+ Assert.All(diff.Lines, l => Assert.Equal(DiffLineType.Added, l.LineType));
+ Assert.Contains("new file", diff.Summary);
+ }
+
+ [Fact]
+ public void UndoChanges_RestoresModifiedContent()
+ {
+ using var repo = new TempRepo();
+ repo.Write("a.txt", "original\n");
+ repo.CommitAll();
+ repo.Write("a.txt", "mangled\n");
+
+ Assert.True(GitQuickStatus.TryUndoChanges(repo.Root, "a.txt"));
+ Assert.Equal("original\n", repo.Read("a.txt"));
+ }
+
+ [Fact]
+ public void UndoChanges_ResurrectsDeletedFile()
+ {
+ using var repo = new TempRepo();
+ repo.Write("a.txt", "keep me\n");
+ repo.CommitAll();
+ File.Delete(Path.Combine(repo.Root, "a.txt"));
+
+ Assert.True(GitQuickStatus.TryUndoChanges(repo.Root, "a.txt"));
+ Assert.Equal("keep me\n", repo.Read("a.txt"));
+ }
+
+ [Fact]
+ public void UndoChanges_FailsForUntrackedFile()
+ {
+ using var repo = new TempRepo();
+ repo.Write("seed.txt", "x\n");
+ repo.CommitAll();
+ repo.Write("floating.txt", "no HEAD side\n");
+
+ Assert.False(GitQuickStatus.TryUndoChanges(repo.Root, "floating.txt"));
+ }
+}
diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
new file mode 100644
index 0000000..87da7ed
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop.Tests/WorkspaceDeltaTrackerTests.cs b/src/MandoCode.Desktop.Tests/WorkspaceDeltaTrackerTests.cs
new file mode 100644
index 0000000..614c240
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/WorkspaceDeltaTrackerTests.cs
@@ -0,0 +1,181 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+///
+/// Encodes the manual workspace-notes test matrix as unit tests: external commit vs revert
+/// disambiguation, branch switches, the silence guarantee (no notes when nothing happened,
+/// no repeats), in-conversation commits staying unreported, the pending-capture race guard,
+/// and touched-file tracking for content edits to already-dirty files.
+///
+public sealed class WorkspaceDeltaTrackerTests
+{
+ private static GitBranchInfo Info(string branch = "main", string oid = "aaa111",
+ params (string Path, string Kind)[] changes) =>
+ new(branch,
+ Dirty: changes.Length > 0,
+ Conflicted: changes.Any(c => c.Kind == "!"),
+ Ahead: 0, Behind: 0, Detached: false,
+ Changes: changes.Select(c => new GitChangeEntry(c.Path, c.Kind)).ToList(),
+ Oid: oid);
+
+ /// First emit seeds the baseline and must say nothing.
+ [Fact]
+ public void FirstEmit_SeedsBaseline_Silently()
+ {
+ var t = new WorkspaceDeltaTracker();
+ Assert.Empty(t.EmitDelta(Info(changes: ("a.cs", "M"))));
+ }
+
+ // Manual test #5: two sends back to back with nothing changed — no notes, no repeats.
+ [Fact]
+ public void NothingChanged_StaysSilent_AndNeverRepeats()
+ {
+ var t = new WorkspaceDeltaTracker();
+ var state = Info(changes: ("a.cs", "M"));
+ t.EmitDelta(state); // seed
+ Assert.Empty(t.EmitDelta(state));
+ Assert.Empty(t.EmitDelta(state));
+ }
+
+ // Manual test #3 (external terminal commit): changes gone + HEAD moved = COMMITTED.
+ [Fact]
+ public void ExternalCommit_ReportsCommitted()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1", changes: ("a.cs", "M"))); // seed dirty baseline
+
+ var notes = t.EmitDelta(Info(oid: "oid2")); // clean tree, new commit
+ var note = Assert.Single(notes);
+ Assert.Contains("COMMITTED", note);
+ Assert.Contains("a.cs", note);
+ }
+
+ /// Changes gone but HEAD unchanged = the work was discarded, not saved.
+ [Fact]
+ public void ExternalRevert_ReportsReverted()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1", changes: ("a.cs", "M")));
+
+ var notes = t.EmitDelta(Info(oid: "oid1")); // clean tree, same commit
+ var note = Assert.Single(notes);
+ Assert.Contains("REVERTED", note);
+ Assert.Contains("a.cs", note);
+ }
+
+ // Manual test #4: external branch switch is reported; resolved files get neutral
+ // phrasing because a checkout moves HEAD without committing anything.
+ [Fact]
+ public void BranchSwitch_ReportsBranch_AndNeutralResolution()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(branch: "main", oid: "oid1", changes: ("a.cs", "M")));
+
+ var notes = t.EmitDelta(Info(branch: "feature", oid: "oid2"));
+ Assert.Equal(2, notes.Count);
+ Assert.Contains("from 'main' to 'feature'", notes[0]);
+ Assert.Contains("after the branch change", notes[1]);
+ Assert.DoesNotContain("COMMITTED", notes[1]);
+ }
+
+ // Manual test #6: the agent commits mid-turn → baseline captured AFTER the turn already
+ // reflects the clean tree → next send reports nothing.
+ [Fact]
+ public void InConversationCommit_ProducesNoNotes()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1", changes: ("a.cs", "M"))); // dirty before the turn
+
+ t.MarkCapturePending(); // turn ends (agent committed)
+ t.CaptureBaselineIfPending(Info(oid: "oid2")); // post-turn snapshot: clean
+
+ Assert.Empty(t.EmitDelta(Info(oid: "oid2")));
+ }
+
+ // Manual tests #1 and #7: while the post-turn capture is still pending, the baseline is
+ // stale (predates the agent's own edits) — the tracker must stay silent, not guess.
+ [Fact]
+ public void PendingCapture_SuppressesEmit()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1")); // seed: clean
+
+ t.MarkCapturePending(); // turn just ended, snapshot not landed
+ Assert.Empty(t.EmitDelta(Info(oid: "oid1", changes: ("agent-edit.cs", "M"))));
+
+ // Once the fresh snapshot lands, normal service resumes without misreporting.
+ t.CaptureBaselineIfPending(Info(oid: "oid1", changes: ("agent-edit.cs", "M")));
+ Assert.Empty(t.EmitDelta(Info(oid: "oid1", changes: ("agent-edit.cs", "M"))));
+ }
+
+ /// A file that becomes dirty between turns is an external change.
+ [Fact]
+ public void NewDirtyFile_ReportsChangedOnDisk()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1")); // seed: clean
+
+ var notes = t.EmitDelta(Info(oid: "oid1", changes: ("b.cs", "M")));
+ var note = Assert.Single(notes);
+ Assert.StartsWith("Files changed on disk:", note);
+ Assert.Contains("b.cs", note);
+ }
+
+ // The mando.txt bug: content edits to an ALREADY-dirty file don't move its status entry,
+ // so only the watcher's touched-set can see them.
+ [Fact]
+ public void TouchedAlreadyDirtyFile_ReportsChangedOnDisk()
+ {
+ var t = new WorkspaceDeltaTracker();
+ var dirty = Info(oid: "oid1", changes: ("mando.txt", "U"));
+ t.EmitDelta(dirty); // seed: already dirty
+
+ t.RecordTouch("mando.txt"); // external content edit
+ var note = Assert.Single(t.EmitDelta(dirty));
+ Assert.Contains("mando.txt", note);
+ Assert.StartsWith("Files changed on disk:", note);
+
+ // Touches are consumed with the emit — no repeat next turn.
+ Assert.Empty(t.EmitDelta(dirty));
+ }
+
+ /// Touches recorded before a re-baseline belong to the old window and must not
+ /// leak into the next one (the undo flow relies on this for its single-mention rule).
+ [Fact]
+ public void Touches_AreClearedByBaselineCapture()
+ {
+ var t = new WorkspaceDeltaTracker();
+ var dirty = Info(oid: "oid1", changes: ("a.cs", "M"));
+ t.EmitDelta(dirty);
+ t.RecordTouch("a.cs");
+
+ t.MarkCapturePending(); // e.g. the undo button fired
+ t.CaptureBaselineIfPending(dirty);
+
+ Assert.Empty(t.EmitDelta(dirty));
+ }
+
+ /// Big external change sets are capped, not dumped.
+ [Fact]
+ public void ManyFiles_AreCapped()
+ {
+ var t = new WorkspaceDeltaTracker();
+ t.EmitDelta(Info(oid: "oid1"));
+
+ var many = Enumerable.Range(1, 13).Select(i => ($"f{i:00}.cs", "M")).ToArray();
+ var note = Assert.Single(t.EmitDelta(Info(oid: "oid1", changes: many)));
+ Assert.Contains("(+3 more)", note);
+ }
+
+ /// Non-git folders never produce notes.
+ [Fact]
+ public void NullInfo_StaysSilent()
+ {
+ var t = new WorkspaceDeltaTracker();
+ Assert.Empty(t.EmitDelta(null));
+ Assert.Empty(t.EmitDelta(Info(changes: ("a.cs", "M")))); // first real snapshot seeds
+ Assert.Empty(t.EmitDelta(null)); // repo vanished — still quiet
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
index 1a4ec11..bfc3f77 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
@@ -16,6 +16,7 @@
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
- Called at send time: queues notes for whatever changed outside the
+ /// conversation since the last turn ended, then re-baselines.
+ private void EmitWorkspaceDelta()
+ {
+ foreach (var note in _wsTracker.EmitDelta(_lastGitInfo))
+ _controller.NoteWorkspaceEvent(note);
+ }
+
+ // ============================================================
+ // Git status strip
+ // ============================================================
+
+ private int _branchRefreshSeq;
+ private DateTime _lastBranchRefresh = DateTime.MinValue;
+ private string? _lastGitRoot;
+ private readonly ObservableCollection _changes = new();
+
+ /// Fire-and-forget refresh of the bottom status strip AND the explorer's Changes
+ /// tab (one git call feeds both). Throttled (UpdateHeader runs on every controller state
+ /// change) except when the root changed; sequence-guarded so an older, slower git call
+ /// can never overwrite a newer result; any failure just hides the strip.
+ private async void RefreshBranchChip(bool force = false)
+ {
+ var root = _controller.ProjectRootPath;
+ if (root != _lastGitRoot) force = true; // never show the previous folder's state
+ if (!force && (DateTime.UtcNow - _lastBranchRefresh).TotalSeconds < 2) return;
+ _lastBranchRefresh = DateTime.UtcNow;
+ _lastGitRoot = root;
+
+ var seq = ++_branchRefreshSeq;
+ var info = await Task.Run(() => GitQuickStatus.TryGet(root));
+
+ if (_shutDown || seq != _branchRefreshSeq) return;
+ _lastGitInfo = info;
+ UpdateChangesList(info, root);
+ _wsTracker.CaptureBaselineIfPending(info);
+ if (info == null)
+ {
+ StatusStrip.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ BranchText.Text = info.Branch
+ + (info.Ahead > 0 ? $" ↑{info.Ahead}" : "")
+ + (info.Behind > 0 ? $" ↓{info.Behind}" : "");
+
+ // One status light: conflicts trump dirty trumps clean.
+ var (dotBrush, state) =
+ info.Conflicted ? ("MandoRedBrush", "merge conflicts")
+ : info.Dirty ? ("MandoGoldBrush", "uncommitted changes")
+ : ("MandoGreenBrush", "clean");
+ BranchDot.Fill = Application.Current.Resources[dotBrush] as Brush;
+
+ var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
+ Path.TrimEndingDirectorySeparator(info.RepoRoot),
+ Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
+ ToolTipService.SetToolTip(StatusStrip,
+ (info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
+ + " — " + state
+ + (info.Ahead > 0 || info.Behind > 0
+ ? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
+ // Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
+ + (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
+ StatusStrip.Visibility = Visibility.Visible;
+ }
+
+ /// Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).
+ private void UpdateChangesList(GitBranchInfo? info, string root)
+ {
+ if (ChangesList.ItemsSource == null) ChangesList.ItemsSource = _changes;
+
+ // Rebuilding the collection re-realizes every ListView row — a visible flash — so
+ // bail when this snapshot is identical to what's already shown (the common case:
+ // most refreshes confirm state rather than change it). Badges derive from the same
+ // data, so they can't have changed either.
+ var incoming = info?.Changes ?? (IReadOnlyList)Array.Empty();
+ if (incoming.Count == _changes.Count)
+ {
+ var identical = true;
+ for (var i = 0; i < incoming.Count; i++)
+ {
+ if (incoming[i].RelPath != _changes[i].RelPath || incoming[i].Kind != _changes[i].Kind)
+ {
+ identical = false;
+ break;
+ }
+ }
+ if (identical) return;
+ }
+
+ _changes.Clear();
+ if (info != null)
+ {
+ foreach (var c in info.Changes)
+ {
+ var relNative = c.RelPath.Replace('/', Path.DirectorySeparatorChar);
+ _changes.Add(new GitChangeItem
+ {
+ Kind = c.Kind,
+ KindBrush = BrushForKind(c.Kind),
+ KindLabel = c.Kind switch
+ {
+ "!" => "Merge conflict",
+ "U" => "Untracked (new, not yet added)",
+ "A" => "Added",
+ "D" => "Deleted",
+ "R" => "Renamed",
+ _ => "Modified",
+ },
+ Name = Path.GetFileName(c.RelPath.TrimEnd('/')),
+ Dir = Path.GetDirectoryName(relNative)?.Replace(Path.DirectorySeparatorChar, '/') ?? "",
+ FullPath = Path.Combine(root, relNative),
+ RelPath = c.RelPath,
+ TagTooltip = $"Tag in prompt — inserts @{c.RelPath}",
+ });
+ }
+ }
+
+ ChangesTabButton.Content = _changes.Count > 0 ? $"Changes ({_changes.Count})" : "Changes";
+ ChangesEmptyText.Visibility = _changesTabActive && _changes.Count == 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ CommitButton.IsEnabled = _changes.Count > 0;
+
+ RebuildDirtySets(info);
+ RefreshExplorerDirtyFlags();
+ }
+
+ // --- dirty badges on the file tree ---
+ // A changed file gets a gold dot; every ancestor folder gets one too, so a collapsed
+ // folder still signals "something inside changed" (VS Code's badge behavior).
+
+ private readonly HashSet _gitDirtyFiles = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _gitDirtyDirs = new(StringComparer.OrdinalIgnoreCase);
+
+ private void RebuildDirtySets(GitBranchInfo? info)
+ {
+ _gitDirtyFiles.Clear();
+ _gitDirtyDirs.Clear();
+ if (info == null) return;
+ foreach (var c in info.Changes)
+ {
+ var rel = c.RelPath.TrimEnd('/');
+ // Untracked directories arrive as one "dir/" entry — that's a dir badge, not a file.
+ if (c.RelPath.EndsWith('/')) _gitDirtyDirs.Add(rel);
+ else _gitDirtyFiles.Add(rel);
+ for (var slash = rel.LastIndexOf('/'); slash > 0; slash = rel.LastIndexOf('/'))
+ {
+ rel = rel[..slash];
+ _gitDirtyDirs.Add(rel);
+ }
+ }
+ }
+
+ /// Re-flags every REALIZED tree node in place (expansion state survives).
+ /// Nodes created later pick their flag up at creation in LoadChildNodes.
+ private void RefreshExplorerDirtyFlags()
+ {
+ Walk(ExplorerTree.RootNodes);
+
+ void Walk(IList nodes)
+ {
+ foreach (var node in nodes)
+ {
+ if (node.Content is ExplorerItem item) item.Dirty = IsItemDirty(item);
+ if (node.Children.Count > 0) Walk(node.Children);
+ }
+ }
}
+ private bool IsItemDirty(ExplorerItem item) =>
+ item.IsDirectory ? _gitDirtyDirs.Contains(item.RelPath) : _gitDirtyFiles.Contains(item.RelPath);
+
+ private static Brush? BrushForKind(string kind) =>
+ Application.Current.Resources[kind switch
+ {
+ "!" or "D" => "MandoRedBrush",
+ "A" or "U" => "MandoGreenBrush",
+ "R" => "MandoSkyBrush",
+ _ => "MandoGoldBrush",
+ }] as Brush;
+
private void UpdatePlanProgress(int done, int total, bool active)
{
PlanProgressPanel.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
@@ -714,7 +920,33 @@ await Task.Run(async () =>
private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen);
private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false);
- private void ExplorerRefresh_Click(object sender, RoutedEventArgs e) => BuildExplorerRoot();
+
+ private void ExplorerRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ BuildExplorerRoot();
+ RefreshBranchChip(force: true); // the Changes tab re-reads too
+ }
+
+ // --- Files / Changes tabs ---
+
+ private bool _changesTabActive;
+
+ private void FilesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: false);
+ private void ChangesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: true);
+
+ private void SetExplorerTab(bool changes)
+ {
+ _changesTabActive = changes;
+ ExplorerTree.Visibility = changes ? Visibility.Collapsed : Visibility.Visible;
+ ChangesList.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+ ChangesEmptyText.Visibility = changes && _changes.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ChangesFooter.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+ CommitButton.IsEnabled = _changes.Count > 0;
+ FilesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.Normal : Microsoft.UI.Text.FontWeights.SemiBold;
+ ChangesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
+ FilesTabButton.Opacity = changes ? 0.55 : 1;
+ ChangesTabButton.Opacity = changes ? 1 : 0.55;
+ }
private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e)
{
@@ -800,6 +1032,216 @@ private void BuildExplorerRoot()
ToolTipService.SetToolTip(ExplorerRootText, _explorerRoot);
ExplorerTree.RootNodes.Clear();
foreach (var node in LoadChildNodes(_explorerRoot)) ExplorerTree.RootNodes.Add(node);
+ StartExplorerWatcher(_explorerRoot);
+ }
+
+ // --- filesystem watcher: the tree follows external creates/deletes/renames on its own ---
+ // Efficiency comes from three choices: (1) only NAME notifications — content writes don't
+ // change tree shape; (2) events debounce into one flush, so a build touching 500 files
+ // costs one pass; (3) a flush re-syncs only REALIZED directory nodes — churn under a
+ // never-expanded folder (node_modules, bin/obj) is a hash lookup and a skip, because
+ // lazy loading will read the truth from disk whenever it's finally expanded.
+
+ private FileSystemWatcher? _fsWatcher;
+ private readonly object _fsLock = new();
+ private readonly HashSet _pendingFsDirs = new(StringComparer.OrdinalIgnoreCase);
+ private bool _fsFlushQueued;
+ private bool _fsSyncAll; // watcher buffer overflowed — re-sync every realized dir
+
+ private void StartExplorerWatcher(string root)
+ {
+ StopExplorerWatcher();
+ try
+ {
+ _fsWatcher = new FileSystemWatcher(root)
+ {
+ IncludeSubdirectories = true,
+ // LastWrite so EDITS refresh git state (M rows, badges, dirty dot) — name
+ // events alone only cover tree shape. Content writes are routed git-only
+ // below: they can't change the tree, so they never trigger tree syncs.
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
+ InternalBufferSize = 64 * 1024, // max — fewer overflows during big builds
+ };
+ _fsWatcher.Created += (_, e) => QueueFsEvent(e.FullPath);
+ _fsWatcher.Deleted += (_, e) => QueueFsEvent(e.FullPath);
+ _fsWatcher.Renamed += (_, e) => { QueueFsEvent(e.OldFullPath); QueueFsEvent(e.FullPath); };
+ _fsWatcher.Changed += (_, e) => QueueFsEvent(e.FullPath, treeRelevant: false);
+ _fsWatcher.Error += (_, _) => { lock (_fsLock) { _fsSyncAll = true; } QueueFsEvent(root); };
+ _fsWatcher.EnableRaisingEvents = true;
+ }
+ catch
+ {
+ _fsWatcher = null; // best-effort — the refresh button still exists
+ }
+ }
+
+ private void StopExplorerWatcher()
+ {
+ try { _fsWatcher?.Dispose(); } catch { }
+ _fsWatcher = null;
+ }
+
+ /// Threadpool-side: coalesce this event's parent directory into the pending set
+ /// and arm one debounced flush. .git churn and content-only writes skip the tree but
+ /// still refresh git state — that's how external edits, branch switches, and commits
+ /// show up without a manual refresh.
+ private void QueueFsEvent(string fullPath, bool treeRelevant = true)
+ {
+ bool arm;
+ lock (_fsLock)
+ {
+ var rel = ToRelOrNull(fullPath)?.Replace('\\', '/');
+ if (rel == null) return;
+ var isGit = rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase);
+
+ // Our OWN git calls write .git/index (+ transient *.lock files) — reacting to
+ // those would refresh forever: refresh → git status → index event → refresh…
+ // Ignore them; real external actions (checkout, commit) also touch HEAD/refs,
+ // which still get through and trigger the refresh we want.
+ if (isGit && (rel.EndsWith("/index", StringComparison.OrdinalIgnoreCase)
+ || rel.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)))
+ return;
+
+ if (!isGit && treeRelevant)
+ _pendingFsDirs.Add(Path.GetDirectoryName(fullPath) ?? "");
+
+ // Workspace notes: remember WHICH files were touched while the agent was idle.
+ // Status-snapshot diffing alone misses content edits to files that were ALREADY
+ // dirty/untracked (their status entry doesn't change) — this set fills that gap.
+ // Idle-gated so the agent's own writes never count as external.
+ if (!isGit && !_controller.IsProcessing)
+ _wsTracker.RecordTouch(rel);
+
+ arm = !_fsFlushQueued;
+ _fsFlushQueued = true;
+ }
+ if (arm) _ = FlushFsEventsAsync();
+
+ string? ToRelOrNull(string p)
+ {
+ var root = _explorerRoot;
+ if (root == null) return null;
+ var prefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
+ return p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? p[prefix.Length..] : null;
+ }
+ }
+
+ private async Task FlushFsEventsAsync()
+ {
+ await Task.Delay(800); // coalesce the burst
+ List dirs;
+ bool syncAll;
+ lock (_fsLock)
+ {
+ syncAll = _fsSyncAll;
+ _fsSyncAll = false;
+ dirs = _pendingFsDirs.ToList();
+ _pendingFsDirs.Clear();
+ _fsFlushQueued = false;
+ }
+ OnUi(() =>
+ {
+ if (_shutDown) return;
+ if (syncAll) SyncAllRealizedDirs();
+ else foreach (var dir in dirs) SyncRealizedDir(dir);
+ RefreshBranchChip(force: true); // badges, Changes tab, and status strip follow
+ });
+ }
+
+ /// Re-syncs one directory's children IF that directory is realized in the tree;
+ /// unexpanded directories are skipped (lazy load reads fresh from disk anyway).
+ private void SyncRealizedDir(string dir)
+ {
+ var list = FindRealizedChildList(dir);
+ if (list != null) SyncDirectoryNode(list, dir);
+ }
+
+ private void SyncAllRealizedDirs()
+ {
+ var root = _explorerRoot;
+ if (root == null) return;
+ SyncDirectoryNode(ExplorerTree.RootNodes, root);
+ Walk(ExplorerTree.RootNodes);
+
+ void Walk(IList nodes)
+ {
+ foreach (var n in nodes)
+ {
+ if (n is { HasUnrealizedChildren: false, Content: ExplorerItem { IsDirectory: true } item })
+ {
+ SyncDirectoryNode(n.Children, item.FullPath);
+ Walk(n.Children);
+ }
+ }
+ }
+ }
+
+ private IList? FindRealizedChildList(string dir)
+ {
+ var root = _explorerRoot;
+ if (root == null) return null;
+ if (PathsEqual(dir, root)) return ExplorerTree.RootNodes;
+ return Find(ExplorerTree.RootNodes);
+
+ IList? Find(IList nodes)
+ {
+ foreach (var n in nodes)
+ {
+ if (n.Content is ExplorerItem { IsDirectory: true } item && PathsEqual(item.FullPath, dir))
+ return n.HasUnrealizedChildren ? null : n.Children;
+ if (n.Children.Count > 0)
+ {
+ var found = Find(n.Children);
+ if (found != null) return found;
+ }
+ }
+ return null;
+ }
+
+ static bool PathsEqual(string a, string b) => string.Equals(
+ Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Minimal diff of a realized directory node against disk: remove rows whose
+ /// path vanished, insert new rows at their sorted position. Never rebuilds surviving
+ /// nodes, so expansion state below them is preserved.
+ private void SyncDirectoryNode(IList children, string dir)
+ {
+ var root = _explorerRoot ?? _controller.ProjectRootPath;
+ string[] dirs, files;
+ try
+ {
+ dirs = Directory.GetDirectories(dir);
+ files = Directory.GetFiles(dir);
+ }
+ catch (Exception) { return; }
+ Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
+ Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+
+ var desired = new List<(string Path, bool IsDir)>(dirs.Length + files.Length);
+ foreach (var d in dirs) desired.Add((d, true));
+ foreach (var f in files) desired.Add((f, false));
+ var desiredSet = new HashSet(desired.Select(x => x.Path), StringComparer.OrdinalIgnoreCase);
+
+ for (var i = children.Count - 1; i >= 0; i--)
+ if (children[i].Content is ExplorerItem it && !desiredSet.Contains(it.FullPath))
+ children.RemoveAt(i);
+
+ var existing = new HashSet(
+ children.Select(n => (n.Content as ExplorerItem)?.FullPath ?? ""),
+ StringComparer.OrdinalIgnoreCase);
+
+ for (var idx = 0; idx < desired.Count; idx++)
+ {
+ var (path, isDir) = desired[idx];
+ if (existing.Contains(path)) continue;
+ var item = isDir ? ExplorerItem.ForFolder(path, root) : ExplorerItem.ForFile(path, root);
+ item.Dirty = IsItemDirty(item);
+ var node = new TreeViewNode { Content = item };
+ if (isDir) node.HasUnrealizedChildren = true;
+ children.Insert(Math.Min(idx, children.Count), node);
+ }
}
/// One directory level, folders first then files, both alphabetical. Unreadable
@@ -818,18 +1260,129 @@ private List LoadChildNodes(string dir)
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
Array.Sort(files, StringComparer.OrdinalIgnoreCase);
foreach (var d in dirs)
- nodes.Add(new TreeViewNode { Content = ExplorerItem.ForFolder(d, root), HasUnrealizedChildren = true });
+ {
+ var item = ExplorerItem.ForFolder(d, root);
+ item.Dirty = IsItemDirty(item);
+ nodes.Add(new TreeViewNode { Content = item, HasUnrealizedChildren = true });
+ }
foreach (var f in files)
- nodes.Add(new TreeViewNode { Content = ExplorerItem.ForFile(f, root) });
+ {
+ var item = ExplorerItem.ForFile(f, root);
+ item.Dirty = IsItemDirty(item);
+ nodes.Add(new TreeViewNode { Content = item });
+ }
return nodes;
}
- /// The row's @ button: tags the file/folder in the prompt — identical result to
- /// dragging the row onto the input box.
+ /// The row's @ button — shared by the file tree (TreeViewNode rows) and the
+ /// Changes list (GitChangeItem rows): tags the file/folder in the prompt, identical
+ /// result to dragging the row onto the input box.
private void ExplorerTag_Click(object sender, RoutedEventArgs e)
{
- if ((sender as FrameworkElement)?.DataContext is TreeViewNode { Content: ExplorerItem item })
- InsertFileTokens(new[] { item.FullPath });
+ var ctx = (sender as FrameworkElement)?.DataContext;
+ var path = ctx switch
+ {
+ TreeViewNode { Content: ExplorerItem item } => item.FullPath,
+ GitChangeItem change => change.FullPath,
+ _ => null,
+ };
+ if (path != null) InsertFileTokens(new[] { path });
+ }
+
+ private void ChangesList_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
+ {
+ var paths = e.Items.OfType().Select(c => c.FullPath).ToList();
+ if (paths.Count == 0) { e.Cancel = true; return; }
+ e.Data.SetText(string.Join("\n", paths));
+ e.Data.RequestedOperation = DataPackageOperation.Copy;
+ }
+
+ /// The row's ± button: show this file's diff as a transcript DiffCard. An
+ /// explicit button (not row click) so selecting or starting a drag never spawns a card,
+ /// and no click-vs-double-click disambiguation delay is needed.
+ private async void ChangesDiff_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is not GitChangeItem item || _shutDown) return;
+
+ var root = _controller.ProjectRootPath;
+ var diff = await Task.Run(() => GitQuickStatus.TryGetDiff(root, item.RelPath, untracked: item.Kind == "U"));
+ if (_shutDown) return;
+
+ if (diff == null)
+ _transcript.Append(_html.Warn($"Couldn't get a diff for {item.RelPath}"));
+ else if (diff.Lines.Count == 0)
+ _transcript.Append(_html.Dim($"{item.RelPath}: {diff.Summary}"));
+ else
+ _transcript.Append(_html.DiffCard(item.RelPath, diff.Lines, diff.Summary, interactive: true));
+ }
+
+ /// Pre-fills the prompt with a commit request — never sends, never commits.
+ /// Caret-aware insert, so tagging files first then clicking Commit… composes naturally
+ /// ("@a.cs @b.cs Commit the current changes…"). The user can edit, then sends; the
+ /// bottom-bar approval gates the actual git command.
+ private void Commit_Click(object sender, RoutedEventArgs e) =>
+ InsertAtCaret("Commit the current changes with an appropriate message");
+
+ private void ChangeUndo_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is GitChangeItem item)
+ UndoFileFromCard(item.RelPath);
+ }
+
+ /// Fire-and-forget bridge for non-async call sites (web message handler, row
+ /// button). async void is safe here: ConfirmAndUndoAsync catches nothing fatal — git
+ /// failure is reported to the transcript, not thrown.
+ private async void UndoFileFromCard(string relPath) => await ConfirmAndUndoAsync(relPath);
+
+ /// The one destructive action in the app, so it always confirms first —
+ /// whether it came from a Changes row or a diff card's Undo chip.
+ private async Task ConfirmAndUndoAsync(string relPath)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "Discard changes?",
+ Content = $"{relPath} will be restored to its state at the last commit. This can't be undone.",
+ PrimaryButtonText = "Discard changes",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ var root = _controller.ProjectRootPath;
+ var ok = await Task.Run(() => GitQuickStatus.TryUndoChanges(root, relPath));
+ if (_shutDown) return;
+ _transcript.Append(ok
+ ? _html.Success($"Restored {relPath} to its state at the last commit.")
+ : _html.Warn($"Couldn't restore {relPath} — is it still tracked by git?"));
+ if (ok)
+ {
+ // Tell the model explicitly — discarding its work is feedback, not just a file
+ // event — and re-baseline so the generic delta doesn't report it a second time.
+ _controller.NoteWorkspaceEvent(
+ $"The user DISCARDED all uncommitted changes to {relPath} (restored to the last commit). " +
+ "If you changed that file earlier, those changes are gone by the user's choice — don't re-apply them unless asked.");
+ _wsTracker.MarkCapturePending();
+ }
+ RefreshBranchChip(force: true);
+ }
+
+ private void ChangesList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ if ((e.OriginalSource as FrameworkElement)?.DataContext is not GitChangeItem item) return;
+ if (!File.Exists(item.FullPath)) return; // deleted entries have nothing to open
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = item.FullPath,
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+ }
}
private void ExplorerTag_PointerEntered(object sender, PointerRoutedEventArgs e)
@@ -1039,6 +1592,7 @@ private void SubmitCurrentInput()
var text = InputBox.Text;
if (string.IsNullOrWhiteSpace(text) || _controller.IsProcessing) return;
+ EmitWorkspaceDelta(); // queue outside-the-conversation changes before this send
InputBox.Text = "";
HideSuggestions();
UpdateHeader();
@@ -1550,7 +2104,23 @@ private void HideApprovalOverlay()
/// transcript (the plan stays readable), but it DOES gate input — the turn is awaiting the choice.
private void ShowPlanApprovalBar(ApprovalRequest request, Action onChosen)
{
+ // Windows 98 theme: the bar drops its rounded card look and reads as a silver
+ // dialog strip — square corners, dialog-face background. Rebuilt on every show,
+ // so live theme switches take effect on the next approval.
+ var win98 = ThemeManager.Current.Win98;
+ PlanApprovalBar.CornerRadius = new CornerRadius(win98 ? 0 : 12);
+ PlanApprovalBar.Background = (Brush)Application.Current.Resources[
+ win98 ? "MandoBackgroundBrush" : "MandoPanelBrush"];
+
PlanApprovalTitle.Text = request.Title;
+
+ // Command approvals ride this bar too: show the command in monospace. The buttons
+ // live in a WrapPanel — one horizontal row whenever it fits, wrapping only when the
+ // window is too narrow for the long "don't ask again" labels.
+ PlanApprovalCommand.Text = string.IsNullOrEmpty(request.CommandText) ? "" : "$ " + request.CommandText;
+ PlanApprovalCommand.Visibility = string.IsNullOrEmpty(request.CommandText)
+ ? Visibility.Collapsed : Visibility.Visible;
+
PlanApprovalButtons.Children.Clear();
foreach (var option in request.Options)
{
@@ -1560,6 +2130,7 @@ private void ShowPlanApprovalBar(ApprovalRequest request, Action onChose
content.Children.Add(new TextBlock { Text = option.Label });
var button = new Button { Content = content, Tag = option.Label, Padding = new Thickness(14, 6, 14, 6) };
+ if (win98) button.CornerRadius = new CornerRadius(0); // square, like every 98 control
if (option.Kind == ApprovalOptionKind.Proceed)
button.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; // primary
else
@@ -1633,18 +2204,41 @@ public ModelItem(string name, string badge, Brush badgeForeground, Brush badgeBa
/// One row in the file-explorer tree. Folder nodes are created with unrealized
/// children and lazy-load their contents on first expand (ChatTabView.ExplorerTree_Expanding).
-public sealed class ExplorerItem
+public sealed class ExplorerItem : System.ComponentModel.INotifyPropertyChanged
{
public string Name { get; private init; } = "";
public string FullPath { get; private init; } = "";
public bool IsDirectory { get; private init; }
+ /// Root-relative path with forward slashes \u2014 the key used to match this row
+ /// against git change entries.
+ public string RelPath { get; private init; } = "";
+
/// The exact @token the row produces (root-relative, forward slashes, trailing
/// '/' on folders) \u2014 shown in the tag button's tooltip so hovering teaches the @ syntax.
public string Token { get; private init; } = "";
public string TagTooltip => $"Tag in prompt \u2014 inserts {Token}";
+ /// Files: this file has uncommitted changes. Folders: something inside does.
+ /// Mutable + observable so rows already realized in the tree light up in place when a
+ /// git refresh lands (rebuilding the tree would lose expansion state).
+ public bool Dirty
+ {
+ get => _dirty;
+ set
+ {
+ if (_dirty == value) return;
+ _dirty = value;
+ PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DirtyVisibility)));
+ }
+ }
+ private bool _dirty;
+
+ public Visibility DirtyVisibility => _dirty ? Visibility.Visible : Visibility.Collapsed;
+
+ public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
+
public string Glyph => IsDirectory ? "\uE8B7" : "\uE8A5"; // folder / document
/// Resolved per-realization from app resources, so icons pick up live theme
@@ -1652,12 +2246,43 @@ public sealed class ExplorerItem
public Brush? IconBrush =>
Application.Current.Resources[IsDirectory ? "MandoGoldBrush" : "MandoDimBrush"] as Brush;
- public static ExplorerItem ForFolder(string path, string root) =>
- new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, Token = "@" + Rel(path, root) + "/" };
+ public static ExplorerItem ForFolder(string path, string root)
+ {
+ var rel = Rel(path, root);
+ return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, RelPath = rel, Token = "@" + rel + "/" };
+ }
- public static ExplorerItem ForFile(string path, string root) =>
- new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, Token = "@" + Rel(path, root) };
+ public static ExplorerItem ForFile(string path, string root)
+ {
+ var rel = Rel(path, root);
+ return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, RelPath = rel, Token = "@" + rel };
+ }
private static string Rel(string path, string root) =>
Path.GetRelativePath(root, path).Replace('\\', '/');
}
+
+/// One row in the explorer's Changes tab: a working-tree change with its display
+/// letter/color, split name + directory, and the @token its tag button inserts. Built on
+/// the UI thread from a GitQuickStatus snapshot, so it carries ready-made brushes
+/// (same pattern as ModelItem).
+public sealed class GitChangeItem
+{
+ public string Kind { get; init; } = "";
+ public string KindLabel { get; init; } = "";
+ public Brush? KindBrush { get; init; }
+ public string Name { get; init; } = "";
+ public string Dir { get; init; } = "";
+ public string FullPath { get; init; } = "";
+ public string RelPath { get; init; } = "";
+ public string TagTooltip { get; init; } = "";
+
+ /// Undo restores from HEAD, so it needs a HEAD side: hidden for untracked rows
+ /// ("undoing" a new file would DELETE it — different action, different UI) and renamed
+ /// rows (a clean rename-undo needs both paths).
+ public Visibility UndoVisibility => Kind is "M" or "D" or "!" ? Visibility.Visible : Visibility.Collapsed;
+
+ public string UndoTooltip => Kind == "D"
+ ? "Restore this deleted file"
+ : "Undo changes — restore this file to the last commit (asks first)";
+}
diff --git a/src/MandoCode.Desktop/Controls/WrapPanel.cs b/src/MandoCode.Desktop/Controls/WrapPanel.cs
new file mode 100644
index 0000000..e9ce5f3
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/WrapPanel.cs
@@ -0,0 +1,64 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace MandoCode.Desktop.Controls;
+
+///
+/// Left-to-right flow layout that wraps to the next line only when a child won't fit the
+/// remaining width. WinUI 3 ships no wrap panel for variable-width children
+/// (VariableSizedWrapGrid is uniform-cell), so the approval bar's option buttons — which
+/// should read horizontally but must survive narrow windows and long labels — use this.
+///
+public sealed class WrapPanel : Panel
+{
+ public double HorizontalSpacing { get; set; } = 8;
+ public double VerticalSpacing { get; set; } = 8;
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ double lineWidth = 0, lineHeight = 0, maxWidth = 0, totalHeight = 0;
+ foreach (var child in Children)
+ {
+ child.Measure(new Size(availableSize.Width, double.PositiveInfinity));
+ var d = child.DesiredSize;
+ if (lineWidth > 0 && lineWidth + HorizontalSpacing + d.Width > availableSize.Width)
+ {
+ maxWidth = Math.Max(maxWidth, lineWidth);
+ totalHeight += lineHeight + VerticalSpacing;
+ lineWidth = d.Width;
+ lineHeight = d.Height;
+ }
+ else
+ {
+ lineWidth += (lineWidth > 0 ? HorizontalSpacing : 0) + d.Width;
+ lineHeight = Math.Max(lineHeight, d.Height);
+ }
+ }
+ maxWidth = Math.Max(maxWidth, lineWidth);
+ totalHeight += lineHeight;
+ return new Size(
+ double.IsInfinity(availableSize.Width) ? maxWidth : Math.Min(maxWidth, availableSize.Width),
+ totalHeight);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ double x = 0, y = 0, lineHeight = 0;
+ foreach (var child in Children)
+ {
+ var d = child.DesiredSize;
+ if (x > 0 && x + HorizontalSpacing + d.Width > finalSize.Width)
+ {
+ y += lineHeight + VerticalSpacing;
+ x = 0;
+ lineHeight = 0;
+ }
+ if (x > 0) x += HorizontalSpacing;
+ child.Arrange(new Rect(x, y, d.Width, d.Height));
+ x += d.Width;
+ lineHeight = Math.Max(lineHeight, d.Height);
+ }
+ return finalSize;
+ }
+}
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 7d33188..0452534 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -557,24 +557,14 @@
-
-
+
-
-
-
-
-
-
-
-
+
@@ -596,6 +586,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (_previewWebReady) return;
+ _previewWebReady = true;
+ _ = SeedBgPreviewAsync();
+ };
+ core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
+ }
+ catch { /* no preview — settings still fully functional */ }
+ }
+
+ private async Task SeedBgPreviewAsync()
+ {
+ try
+ {
+ var blocks =
+ _html.UserEcho("how does this look?") +
+ _html.AssistantCard(
+ "Like this — the image fades, the text never does.\n\n" +
+ "Inline `code` and a block, to judge every surface:\n\n" +
+ "```csharp\nvar vibe = \"immaculate\";\n```");
+ await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ "window.__append(" + JsonSerializer.Serialize(blocks) + ");");
+ await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ ThemeManager.BuildTranscriptScript(ThemeManager.Current));
+ }
+ catch { }
}
private void OnUi(Action action)
@@ -410,6 +476,10 @@ private static bool IsDown(VirtualKey key) =>
private void ApplyThemeToAllTabs()
{
foreach (var tab in _tabs) tab.View.ApplyTheme();
+ // The appearance preview is a transcript too — it re-themes with everyone else.
+ if (_previewWebReady && BgPreviewWeb.CoreWebView2 != null)
+ _ = BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ ThemeManager.BuildTranscriptScript(ThemeManager.Current));
}
private void CopyToClipboard(string text)
@@ -1155,11 +1225,17 @@ private void BgClear_Click(object sender, RoutedEventArgs e)
ApplyThemeToAllTabs();
}
+ private void BoxedMessages_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (!_appearanceReady) return; // see _appearanceReady — a Save() here wipes settings
+ ThemeManager.SetBoxedMessages(BoxedMessagesToggle.IsOn);
+ ApplyThemeToAllTabs(); // live — existing messages re-skin instantly
+ }
+
private void BgOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
{
if (!_appearanceReady) return; // see _appearanceReady — a Save() here wipes settings
S_BgOpacityLabel.Text = $"{(int)e.NewValue}%";
- BgPreviewImage.Opacity = e.NewValue / 100.0;
ThemeManager.SetChatBackgroundOpacity(e.NewValue / 100.0);
ApplyThemeToAllTabs(); // live preview while dragging — the script is tiny
}
@@ -1170,22 +1246,8 @@ private void UpdateBgControls()
BgFileLabel.Text = hasImage ? "Image set ✓" : "No image set";
BgClearButton.IsEnabled = hasImage;
S_BgOpacity.IsEnabled = hasImage;
- BgPreviewImage.Opacity = ThemeManager.ChatBackgroundOpacity;
-
- // Decode from bytes, not from the file URI — a URI-sourced BitmapImage keeps the
- // file open, and SetChatBackground must be able to overwrite it on the next pick.
- BitmapImage? bmp = null;
- if (hasImage)
- {
- try
- {
- using var ms = new MemoryStream(File.ReadAllBytes(ThemeManager.ChatBackgroundFile!));
- bmp = new BitmapImage();
- bmp.SetSource(ms.AsRandomAccessStream());
- }
- catch { bmp = null; /* unreadable image — preview just shows the theme colors */ }
- }
- BgPreviewImage.Source = bmp;
+ // The preview WebView renders the image itself (via the userdata host + theme
+ // script), so there is no XAML image to update here anymore.
}
// WinUI has no Window.Opacity — whole-window translucency is a Win32 layered-window
diff --git a/src/MandoCode.Desktop/Services/GitQuickStatus.cs b/src/MandoCode.Desktop/Services/GitQuickStatus.cs
new file mode 100644
index 0000000..380ab31
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/GitQuickStatus.cs
@@ -0,0 +1,382 @@
+using System.Diagnostics;
+using MandoCode.Models;
+
+namespace MandoCode.Desktop.Services;
+
+/// A parsed working-tree diff for one file, ready for TranscriptHtmlBuilder.DiffCard.
+/// when the diff blew past the render cap.
+public sealed record GitFileDiff(IReadOnlyList Lines, string Summary, bool Truncated);
+
+/// One working-tree change. is a display letter:
+/// "M" modified, "A" added/staged-new, "D" deleted, "R" renamed, "U" untracked,
+/// "!" merge conflict.
+public sealed record GitChangeEntry(string RelPath, string Kind);
+
+/// Local git state for the status strip and the explorer's Changes tab.
+/// is the short SHA when .
+/// means unmerged paths exist (mid-merge/rebase) — a louder
+/// state than plain .
+/// is the full HEAD commit hash — comparing it across
+/// snapshots distinguishes "changes were committed" (HEAD moved) from "changes were
+/// reverted" (HEAD didn't). is the repository's toplevel, which
+/// may be an ANCESTOR of the queried folder (git resolves repos by walking up); when it is,
+/// is scoped to the queried subtree with subtree-relative paths.
+public sealed record GitBranchInfo(
+ string Branch, bool Dirty, bool Conflicted, int Ahead, int Behind, bool Detached,
+ IReadOnlyList Changes, string Oid, string RepoRoot = "");
+
+///
+/// Reads branch/dirty/ahead-behind state by shelling out to git — one
+/// git status --porcelain=v2 --branch call carries all of it. Local-only: no network,
+/// no GitHub API. Any failure (no git on PATH, not a repo, timeout) returns null and the
+/// caller hides the chip; this must never surface an error to the user.
+///
+public static class GitQuickStatus
+{
+ public static GitBranchInfo? TryGet(string root)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) return null;
+
+ // Git resolves a repo by walking UP the tree, so the repo may be an ancestor of
+ // root (a tab opened on a subfolder — or a stray .git in Desktop/home catching
+ // everything under it). The toplevel is surfaced so the UI can disclose it.
+ var toplevel = RunGitLine(root, "rev-parse", "--show-toplevel");
+ if (string.IsNullOrEmpty(toplevel)) return null;
+ var repoRoot = Path.GetFullPath(toplevel.Replace('/', Path.DirectorySeparatorChar));
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = root,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ // relativePaths=true pinned explicitly: porcelain-v2 paths then come relative to
+ // the CWD (our project root) — files elsewhere in an ancestor repo arrive as
+ // "../..." and are filtered below. Verified empirically; a user's global config
+ // must not be able to flip this under us.
+ psi.ArgumentList.Add("-c");
+ psi.ArgumentList.Add("status.relativePaths=true");
+ psi.ArgumentList.Add("status");
+ psi.ArgumentList.Add("--porcelain=v2");
+ psi.ArgumentList.Add("--branch");
+
+ using var p = Process.Start(psi);
+ if (p == null) return null;
+ var output = p.StandardOutput.ReadToEnd();
+ if (!p.WaitForExit(4000))
+ {
+ try { p.Kill(); } catch { }
+ return null;
+ }
+ if (p.ExitCode != 0) return null;
+
+ string branch = "", oid = "";
+ bool dirty = false, conflicted = false, detached = false;
+ int ahead = 0, behind = 0;
+ var changes = new List();
+
+ foreach (var raw in output.Split('\n'))
+ {
+ var line = raw.TrimEnd('\r');
+ if (line.StartsWith("# branch.head ", StringComparison.Ordinal))
+ {
+ branch = line["# branch.head ".Length..].Trim();
+ detached = branch == "(detached)";
+ }
+ else if (line.StartsWith("# branch.oid ", StringComparison.Ordinal))
+ {
+ oid = line["# branch.oid ".Length..].Trim();
+ }
+ else if (line.StartsWith("# branch.ab ", StringComparison.Ordinal))
+ {
+ foreach (var t in line["# branch.ab ".Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (t[0] == '+') int.TryParse(t[1..], out ahead);
+ else if (t[0] == '-') int.TryParse(t[1..], out behind);
+ }
+ }
+ else if (line.Length > 0 && line[0] != '#')
+ {
+ var entry = ParseChangeLine(line);
+ if (entry == null) continue;
+ // Scope to the project-root subtree: paths are CWD-relative (pinned
+ // above), so anything outside arrives as "../…". Churn elsewhere in an
+ // ancestor repo is not this tab's business — dirty/conflicted included.
+ if (entry.RelPath.StartsWith("../", StringComparison.Ordinal)) continue;
+ dirty = true;
+ if (entry.Kind == "!") conflicted = true;
+ changes.Add(entry);
+ }
+ }
+
+ if (detached)
+ branch = oid.Length >= 7 ? oid[..7] : oid; // no branch — show the commit instead
+
+ // Conflicts float to the top; everything else alphabetical.
+ var ordered = changes
+ .OrderBy(c => c.Kind == "!" ? 0 : 1)
+ .ThenBy(c => c.RelPath, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return branch.Length == 0 ? null : new GitBranchInfo(branch, dirty, conflicted, ahead, behind, detached, ordered, oid, repoRoot);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// Parses one non-header porcelain-v2 entry into a display row. Formats:
+ /// "1 XY ..... path" (ordinary), "2 XY ..... path\torig" (rename/copy),
+ /// "u XY ..... path" (unmerged), "? path" (untracked). Unknown shapes → null.
+ private static GitChangeEntry? ParseChangeLine(string line)
+ {
+ try
+ {
+ switch (line[0])
+ {
+ case '1':
+ {
+ var parts = line.Split(' ', 9);
+ return parts.Length == 9 ? new GitChangeEntry(parts[8], KindFromXY(parts[1])) : null;
+ }
+ case '2':
+ {
+ var parts = line.Split(' ', 10);
+ if (parts.Length != 10) return null;
+ var path = parts[9].Split('\t')[0]; // "newPath\toldPath" — show the new one
+ return new GitChangeEntry(path, "R");
+ }
+ case 'u':
+ {
+ var parts = line.Split(' ', 11);
+ return parts.Length == 11 ? new GitChangeEntry(parts[10], "!") : null;
+ }
+ case '?':
+ return new GitChangeEntry(line[2..], "U");
+ default:
+ return null;
+ }
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// Collapses the two-character staged/unstaged state into one display letter,
+ /// preferring the working-tree side when both are set.
+ private static string KindFromXY(string xy)
+ {
+ var c = xy.Length == 2 && xy[1] != '.' ? xy[1] : (xy.Length >= 1 ? xy[0] : 'M');
+ return c switch { 'A' => "A", 'D' => "D", 'R' => "R", _ => "M" };
+ }
+
+ /// One-line git query (e.g. rev-parse). Null on any failure.
+ private static string? RunGitLine(string workingDir, params string[] args)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = workingDir,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ foreach (var a in args) psi.ArgumentList.Add(a);
+ using var p = Process.Start(psi);
+ if (p == null) return null;
+ var output = p.StandardOutput.ReadToEnd();
+ if (!p.WaitForExit(4000))
+ {
+ try { p.Kill(); } catch { }
+ return null;
+ }
+ return p.ExitCode == 0 ? output.Trim() : null;
+ }
+
+ /// Discards a file's uncommitted changes: git checkout HEAD -- path
+ /// restores index + worktree to the last commit (also resurrects a deleted file).
+ /// DESTRUCTIVE and unrecoverable — callers must confirm with the user first.
+ public static bool TryUndoChanges(string root, string relPath)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = root,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ psi.ArgumentList.Add("checkout");
+ psi.ArgumentList.Add("HEAD");
+ psi.ArgumentList.Add("--");
+ psi.ArgumentList.Add(relPath);
+
+ using var p = Process.Start(psi);
+ if (p == null) return false;
+ p.StandardOutput.ReadToEnd();
+ if (!p.WaitForExit(8000))
+ {
+ try { p.Kill(); } catch { }
+ return false;
+ }
+ return p.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private const int MaxDiffLines = 4000; // render cap — a transcript card, not a diff IDE
+
+ /// Working-tree diff of one file vs HEAD (staged + unstaged combined), parsed
+ /// into DiffCard's model. Untracked files render as all-additions (they have no HEAD
+ /// side). Null on any failure; empty Lines when the file is binary or unchanged.
+ public static GitFileDiff? TryGetDiff(string root, string relPath, bool untracked)
+ {
+ try
+ {
+ if (untracked) return DiffForUntracked(root, relPath);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = root,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ psi.ArgumentList.Add("diff");
+ psi.ArgumentList.Add("--no-color");
+ psi.ArgumentList.Add("HEAD");
+ psi.ArgumentList.Add("--");
+ psi.ArgumentList.Add(relPath);
+
+ using var p = Process.Start(psi);
+ if (p == null) return null;
+ var output = p.StandardOutput.ReadToEnd();
+ if (!p.WaitForExit(8000))
+ {
+ try { p.Kill(); } catch { }
+ return null;
+ }
+ if (p.ExitCode != 0) return null;
+
+ if (output.Contains("Binary files ", StringComparison.Ordinal))
+ return new GitFileDiff(Array.Empty(), "binary file — no text diff", false);
+
+ return ParseUnifiedDiff(output);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static GitFileDiff? DiffForUntracked(string root, string relPath)
+ {
+ var full = Path.Combine(root, relPath.Replace('/', Path.DirectorySeparatorChar));
+ if (!File.Exists(full)) return null;
+
+ // Cheap binary sniff: NUL in the first 8k.
+ using (var fs = File.OpenRead(full))
+ {
+ var probe = new byte[Math.Min(8192, fs.Length)];
+ fs.ReadExactly(probe);
+ if (Array.IndexOf(probe, (byte)0) >= 0)
+ return new GitFileDiff(Array.Empty(), "new binary file — no text diff", false);
+ }
+
+ var lines = new List();
+ var truncated = false;
+ var n = 0;
+ foreach (var text in File.ReadLines(full))
+ {
+ if (++n > MaxDiffLines) { truncated = true; break; }
+ lines.Add(new DiffLine { LineType = DiffLineType.Added, Content = text, NewLineNumber = n });
+ }
+ var summary = $"new file — {lines.Count} addition(s)" + (truncated ? $" (showing first {MaxDiffLines} lines)" : "");
+ return new GitFileDiff(lines, summary, truncated);
+ }
+
+ /// Unified-diff hunks → DiffLine rows. Header lines before the first @@ are
+ /// skipped; "\ No newline at end of file" markers are ignored.
+ private static GitFileDiff ParseUnifiedDiff(string output)
+ {
+ var lines = new List();
+ int oldNum = 0, newNum = 0, adds = 0, removes = 0;
+ var inHunk = false;
+ var truncated = false;
+
+ foreach (var raw in output.Split('\n'))
+ {
+ var line = raw.TrimEnd('\r');
+ if (line.StartsWith("@@", StringComparison.Ordinal))
+ {
+ // "@@ -12,5 +14,6 @@ optional section" — starting line numbers per side.
+ var marks = line.Split(' ');
+ if (marks.Length >= 3 &&
+ TryParseHunkStart(marks[1], out oldNum) &&
+ TryParseHunkStart(marks[2], out newNum))
+ {
+ inHunk = true;
+ if (lines.Count > 0) // visual separator between hunks
+ lines.Add(new DiffLine { LineType = DiffLineType.Unchanged, Content = "⋯" });
+ }
+ continue;
+ }
+ // Real context lines are " " + content (never empty) — a zero-length line here is
+ // only the artifact of splitting after the final newline.
+ if (!inHunk || line.Length == 0) continue;
+ if (lines.Count >= MaxDiffLines) { truncated = true; break; }
+
+ switch (line[0])
+ {
+ case '+':
+ lines.Add(new DiffLine { LineType = DiffLineType.Added, Content = line[1..], NewLineNumber = newNum++ });
+ adds++;
+ break;
+ case '-':
+ lines.Add(new DiffLine { LineType = DiffLineType.Removed, Content = line[1..], OldLineNumber = oldNum++ });
+ removes++;
+ break;
+ case '\\':
+ break; // "\ No newline at end of file"
+ default:
+ lines.Add(new DiffLine
+ {
+ LineType = DiffLineType.Unchanged,
+ Content = line[1..],
+ OldLineNumber = oldNum++,
+ NewLineNumber = newNum++,
+ });
+ break;
+ }
+ }
+
+ var summary = $"{removes} deletion(s), {adds} addition(s)"
+ + (truncated ? $" (showing first {MaxDiffLines} lines)" : "");
+ return new GitFileDiff(lines, summary, truncated);
+
+ static bool TryParseHunkStart(string mark, out int start)
+ {
+ // "-12,5" or "+14" → 12 / 14
+ start = 0;
+ var body = mark.TrimStart('-', '+');
+ var comma = body.IndexOf(',');
+ return int.TryParse(comma >= 0 ? body[..comma] : body, out start);
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/ShellRunner.cs b/src/MandoCode.Desktop/Services/ShellRunner.cs
index 255df50..5e1c1ea 100644
--- a/src/MandoCode.Desktop/Services/ShellRunner.cs
+++ b/src/MandoCode.Desktop/Services/ShellRunner.cs
@@ -25,12 +25,14 @@ public ShellRunner(ProjectRootAccessor projectRoot, TranscriptWriter transcript,
_html = html;
}
- public async Task RunAsync(string command)
+ /// Returns (Failed, Output) so the caller can tell the MODEL what the user ran —
+ /// `!` commands render only in the transcript, which the model never sees.
+ public async Task<(bool Failed, string Output)> RunAsync(string command)
{
if (string.IsNullOrWhiteSpace(command))
{
_transcript.Append(_html.Warn("No command given. Usage: !"));
- return;
+ return (true, "");
}
var output = new StringBuilder();
@@ -99,10 +101,12 @@ void Collect(string? line)
if (text.Length == 0) text = "(no output)";
_transcript.Append(_html.CommandOutputCard(command, text, failed));
+ return (failed, text);
}
catch (Exception ex)
{
_transcript.Append(_html.Error($"Failed to run command: {ex.Message}"));
+ return (true, ex.Message);
}
}
}
diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs
index 1590321..6d34aa9 100644
--- a/src/MandoCode.Desktop/Services/ThemeManager.cs
+++ b/src/MandoCode.Desktop/Services/ThemeManager.cs
@@ -37,6 +37,11 @@ public sealed record UiTheme
/// no animation. Gated in the WebView via an html[data-crt] attribute.
public bool Crt { get; init; }
+ /// When true, the transcript wears Windows-98 chrome: square corners, two-tone
+ /// 3D bevels lit from the top-left, navy title-bar gradients on panels, Tahoma, classic
+ /// chunky scrollbars. Gated in the WebView via an html[data-win98] attribute.
+ public bool Win98 { get; init; }
+
public static readonly IReadOnlyList All = new[]
{
// First entry is the default for fresh installs (ThemeManager falls back to All[0]).
@@ -155,6 +160,23 @@ public sealed record UiTheme
Green = "#5A5240", Red = "#2E251E", DiffAdd = "#3B342A",
},
new UiTheme
+ {
+ // The whole palette is era-authentic: 3D-face silver surfaces, white sunken
+ // content wells, the 16-color navy/olive/teal-adjacent accents (hyperlink blue
+ // for links), black text. FlatMotion is period-correct — nothing animated in
+ // 1998. The real costume is the data-win98 CSS in TranscriptHtmlBuilder:
+ // square corners, two-tone bevels, and navy title-bar gradients on every panel.
+ Name = "W98 - Y2K",
+ Description = "Silver bevels, navy title bars, teal desktop. Party like it's 1998. 🖥️",
+ IsLight = true,
+ FlatMotion = true,
+ Win98 = true,
+ Background = "#C0C0C0", Panel = "#FFFFFF", Border = "#808080",
+ Text = "#000000", Dim = "#5A5A5A",
+ Accent = "#000080", Gold = "#806000", Sky = "#0000CC",
+ Green = "#008000", Red = "#B00000", DiffAdd = "#008000",
+ },
+ new UiTheme
{
Name = "Paper Light",
Description = "A clean light theme with royal purple accents.",
@@ -220,6 +242,20 @@ public static class ThemeManager
/// fades — the slider dims the picture, not the conversation.
public static double ChatBackgroundOpacity { get; private set; } = 0.30;
+ /// Boxed messages: each prompt/response renders on its own frosted card in the
+ /// transcript (hard boundaries, easier long-session scanning) instead of the flat
+ /// terminal look. ON by default — cards are the universal chat idiom and the better
+ /// first impression; the flat-density crowd knows where settings live. Theme-agnostic —
+ /// the CSS uses only theme variables. W98 ignores this: its message windows are bespoke.
+ public static bool BoxedMessages { get; private set; } = true;
+
+ public static void SetBoxedMessages(bool on)
+ {
+ BoxedMessages = on;
+ Save();
+ // Caller re-applies to tabs (same contract as SetChatBackgroundOpacity).
+ }
+
/// Raised after a theme is applied so the window can retheme the WebView.
public static event Action? ThemeChanged;
@@ -244,6 +280,8 @@ public static void Initialize(FrameworkElement root)
var saved = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath));
Current = UiTheme.All.FirstOrDefault(t => t.Name == saved?.Theme) ?? Current;
if (saved?.Opacity is > 0) WindowOpacity = Math.Clamp(saved.Opacity, 0.3, 1.0);
+ // Null = setting predates the feature (or fresh file): take the current default.
+ BoxedMessages = saved?.Boxed ?? true;
if (saved?.ChatBgOpacity is > 0) ChatBackgroundOpacity = Math.Clamp(saved.ChatBgOpacity, 0.05, 1.0);
if (!string.IsNullOrEmpty(saved?.ChatBackground))
{
@@ -332,6 +370,7 @@ private static void Save()
Opacity = WindowOpacity,
ChatBackground = ChatBackgroundFile == null ? null : Path.GetFileName(ChatBackgroundFile),
ChatBgOpacity = ChatBackgroundOpacity,
+ Boxed = BoxedMessages,
}));
}
catch { /* persistence is best-effort; the setting is still applied */ }
@@ -400,6 +439,12 @@ public static string BuildTranscriptScript(UiTheme t) =>
(t.Crt
? "document.documentElement.setAttribute('data-crt','1');"
: "document.documentElement.removeAttribute('data-crt');") +
+ (t.Win98
+ ? "document.documentElement.setAttribute('data-win98','1');"
+ : "document.documentElement.removeAttribute('data-win98');") +
+ (BoxedMessages
+ ? "document.documentElement.setAttribute('data-cards','1');"
+ : "document.documentElement.removeAttribute('data-cards');") +
"})();";
private static void SetBrush(ResourceDictionary res, string key, string hex) =>
@@ -426,5 +471,8 @@ private sealed class UiSettings
public double Opacity { get; set; } = 1.0;
public string? ChatBackground { get; set; } // file name inside UserDataFolder
public double ChatBgOpacity { get; set; } = 0.30;
+ /// Nullable on purpose: absent (pre-feature settings file) means "use the
+ /// current default", so changing the default never fights a user's explicit choice.
+ public bool? Boxed { get; set; }
}
}
diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
index 964d4af..fbcf1ea 100644
--- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
@@ -113,10 +113,18 @@ public string CommandCard(string command) =>
public string CommandOutputCard(string command, string output, bool failed = false) =>
$"
$ {E(command)}
{E(output)}
";
- public string DiffCard(string relativePath, IReadOnlyList lines, string summary)
+ /// adds Undo-changes / Clear chips to the header —
+ /// used ONLY for diffs the user requested from the Changes tab, never for diffs the agent
+ /// produces (those are a record of what happened, not an offer to act).
+ public string DiffCard(string relativePath, IReadOnlyList lines, string summary, bool interactive = false)
{
var sb = new StringBuilder();
- sb.Append($"