From 24ae04e1eccafc28ae1c9b419fdda8f3af9af70e Mon Sep 17 00:00:00 2001 From: DevMando Date: Tue, 21 Jul 2026 18:54:05 -0700 Subject: [PATCH] Add git awareness, agent workspace notes, appearance upgrades, and a test suite - Per-tab git status strip and an explorer Changes tab: per-file diffs, undo/restore with confirmation, tag-to-prompt, and a Commit button that drafts the request - Live file watcher keeps the tree, badges, and git state current without refreshing - The agent is now informed when work happens outside the conversation: discarded changes, external edits, commits, branch switches, and user-run shell commands - Command approvals move to the non-covering bottom bar with wrapping buttons - W98 - Y2K theme, frosted boxed message cards (new default), and a live Appearance preview that renders through the real transcript pipeline - New MandoCode.Desktop.Tests project (22 tests) wired into the CI build --- .github/workflows/build.yml | 3 + .../GitQuickStatusTests.cs | 228 ++++++ .../MandoCode.Desktop.Tests.csproj | 26 + .../WorkspaceDeltaTrackerTests.cs | 181 +++++ .../Controls/ChatTabView.xaml | 158 ++++- .../Controls/ChatTabView.xaml.cs | 649 +++++++++++++++++- src/MandoCode.Desktop/Controls/WrapPanel.cs | 64 ++ src/MandoCode.Desktop/MainWindow.xaml | 38 +- src/MandoCode.Desktop/MainWindow.xaml.cs | 96 ++- .../Services/GitQuickStatus.cs | 382 +++++++++++ src/MandoCode.Desktop/Services/ShellRunner.cs | 8 +- .../Services/ThemeManager.cs | 48 ++ .../Services/TranscriptHtmlBuilder.cs | 158 ++++- .../Services/WinUiApprovalService.cs | 5 +- .../Services/WorkspaceDeltaTracker.cs | 113 +++ .../ViewModels/ChatController.cs | 50 +- 16 files changed, 2149 insertions(+), 58 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/GitQuickStatusTests.cs create mode 100644 src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj create mode 100644 src/MandoCode.Desktop.Tests/WorkspaceDeltaTrackerTests.cs create mode 100644 src/MandoCode.Desktop/Controls/WrapPanel.cs create mode 100644 src/MandoCode.Desktop/Services/GitQuickStatus.cs create mode 100644 src/MandoCode.Desktop/Services/WorkspaceDeltaTracker.cs 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 @@ + + + + + + + + + + + + + + + + + @@ -383,7 +501,12 @@ - + + + + @@ -459,11 +582,38 @@ + + + + + + + + + + + + + + + - 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($"
Diff: {FileLink(relativePath)}
");
+        var actions = interactive
+            ? $"" +
+              ""
+            : "";
+        sb.Append($"
Diff: {FileLink(relativePath)}{actions}
");
         AppendDiffLines(sb, lines);
         sb.Append("
"); sb.Append($"
{E(summary)}
"); @@ -262,7 +270,7 @@ public string HelpCard(IEnumerable<(string Command, string Description)> rows) /// the same CSS variables when the theme changes at runtime. public static string BaseDocument(UiTheme theme) => $$""" - + @@ -359,6 +367,127 @@ transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + /* tube-edge vignette */ radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%); } + /* ---- Boxed messages (Appearance toggle, theme-agnostic) --------------------------- + Each prompt/response on its own card surface: hard message boundaries and skimmable + rhythm for long sessions, versus the default flat terminal look. Only theme variables, + so every palette works. Excluded under W98 — its bevelled message windows are bespoke. */ + /* Frosted glass: cards are slightly translucent with a backdrop blur, so a chat + background image glows through without ever fighting the text (the blur is what + preserves contrast over busy wallpapers). Over a plain theme background the effect + degrades to near-solid — no image, no cost to readability. Blur is static compositing, + not per-frame work. */ + html[data-cards]:not([data-win98]) .user-echo { + background: color-mix(in srgb, var(--panel) 82%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--border); border-radius: 10px; + padding: 8px 12px; } + html[data-cards]:not([data-win98]) .assistant { + background: color-mix(in srgb, var(--panel) 82%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--border); border-radius: 10px; + padding: 6px 12px 8px 12px; } + /* Cards sit on the panel color, so code wells inside switch to the bg color to stay + visually recessed (they normally use --panel against a --bg page). */ + html[data-cards]:not([data-win98]) .md pre, + html[data-cards]:not([data-win98]) .md code { background: var(--bg); } + + /* ---- Windows 98 chrome ----------------------------------------------------------- + Scoped to html[data-win98]. The 3D language of 1998: silver surfaces, square corners, + two-tone bevels lit from the top-left (raised = chrome you can press, sunken = wells + that hold content), navy title-bar gradients, Tahoma, and none of the decoration the + era didn't have (radii, soft shadows). Colors come from the theme's CSS variables; + this block only reshapes geometry, bevels, and the title bars. All static — pairs + with the theme's FlatMotion, because nothing animated in 1998. */ + html[data-win98] body { font-family: Tahoma, "MS Sans Serif", "Segoe UI", sans-serif; + /* THE desktop teal. Silver never filled a screen in 1998 — it sat in windows on this. */ + background: #008080; padding: 12px 14px 20px 14px; } + /* Each MESSAGE is its own window on the desktop (not one giant expanding one): user + prompts are small silver windows; assistant responses are windows whose "MandoCode" + label becomes the navy title bar — the hover copy/react chips land on it like window + buttons. Status lines and tool ops sit directly on the teal like desktop icon labels, + with brightened colors (the theme's dark semantic hues are unreadable on teal). + (A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */ + html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px; + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .assistant { background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .assistant-label { + background: linear-gradient(90deg, #000080, #1084D0); color: #FFFFFF; + padding: 3px 10px; margin-bottom: 0; font-weight: 700; } + html[data-win98] .assistant .md { padding: 2px 12px 8px 12px; } + html[data-win98] .line { color: #EAF6F4; } + html[data-win98] .line.info { color: #A8D8FF; } + html[data-win98] .line.success { color: #90EE90; } + html[data-win98] .line.warn { color: #FFE082; } + html[data-win98] .line.error { color: #FF9E8F; } + html[data-win98] .line.dim, html[data-win98] .op-meta, html[data-win98] .token-summary { color: #B8D8D4; } + html[data-win98] .op { color: #EAF6F4; } + html[data-win98] .op-path { color: #EAF6F4; } + html[data-win98] .op-head a.file-link { color: #AAD4FF; border-bottom-color: #AAD4FF; } + /* Op-head semantic colors (WebSearch/WebFetch/Write/Delete glyph classes) are theme-dark + hues built for silver — brighten them on the teal, same mapping as the .line variants. */ + html[data-win98] .op-head.success { color: #90EE90; } + html[data-win98] .op-head.error, html[data-win98] .op-head.red { color: #FF9E8F; } + html[data-win98] .op-head.warn { color: #FFE082; } + html[data-win98] .op-head.info, html[data-win98] .op-head.sky { color: #A8D8FF; } + html[data-win98] .op-head.dim { color: #B8D8D4; } + /* Square EVERYTHING. */ + html[data-win98] .panel, html[data-win98] .chip, html[data-win98] .tool-pill, + html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn, + html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle, + html[data-win98] .md pre, html[data-win98] .md code, html[data-win98] pre.mono-block, + html[data-win98] pre.raw, html[data-win98] .op-detail, html[data-win98] #rx-pop, + html[data-win98] .rx-pill, html[data-win98] #rx-pop .rx { border-radius: 0 !important; } + /* Raised bevel: anything button-like is a silver 3D control. */ + html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn, + html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle, + html[data-win98] .tool-pill, html[data-win98] .chip, html[data-win98] .rx-pill { + background: var(--bg); color: #000; + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; + } + /* ...and presses in like one. */ + html[data-win98] .copy-chip:active, html[data-win98] .expand-btn:active, + html[data-win98] .web-toggle:active, html[data-win98] .dv-btn:active, + html[data-win98] .ue-toggle:active, html[data-win98] .react-ghost:active { + border-color: #404040 #FFFFFF #FFFFFF #404040; + } + /* Panels are little windows: raised silver frame + navy title-bar gradient. */ + html[data-win98] .panel { + background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; + } + html[data-win98] .panel-header { + background: linear-gradient(90deg, #000080, #1084D0); + color: #FFFFFF; border-bottom: none; + } + html[data-win98] .panel-header a.file-link { color: #FFFFFF; border-bottom-color: #9CC2E5; } + /* Content wells are sunken white, like every 98 text box and list view. */ + html[data-win98] .md pre, html[data-win98] pre.cmd, html[data-win98] pre.cmd-out, + html[data-win98] pre.diff, html[data-win98] pre.mono-block, html[data-win98] pre.raw, + html[data-win98] .op-detail { + background: var(--panel); + border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; + } + html[data-win98] .md code { background: var(--panel); border: 1px solid #808080; } + html[data-win98] .md pre code { border: none; } + /* 1998 had no soft shadows. */ + html[data-win98] #rx-pop { box-shadow: none; background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .chip .dot, html[data-win98] .tool-pill .tp-dot { box-shadow: none; } + /* Plan/help tables become 98 list views: sunken white body, RAISED column headers — + the iconic Explorer detail. Row separators in dialog-face gray. */ + html[data-win98] table.plan { background: var(--panel); + border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; } + html[data-win98] table.plan th { background: var(--bg); color: #000; + border: 1px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] table.plan td { border-top: 1px solid #D4D0C8; } + /* Chunky classic scrollbars. */ + html[data-win98] ::-webkit-scrollbar { width: 16px; height: 16px; } + html[data-win98] ::-webkit-scrollbar-track { background: #DFDFDF; } + html[data-win98] ::-webkit-scrollbar-thumb { background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] ::-webkit-scrollbar-corner { background: #DFDFDF; } + /* User prompts: gold marks the user's voice, at normal weight so an 8-line clamped paste reads as text, not a block of emphasis. Only the sigil stays semibold. */ .user-echo { color: var(--gold); white-space: pre-wrap; margin-top: 14px; } @@ -487,6 +616,14 @@ footerless panel (e.g. command output). */ .web-detail { position: relative; margin-top: 4px; } .web-detail[hidden] { display: none; } .web-detail > .op-detail { margin-top: 0; } + /* Action chips on USER-requested diff cards (Changes-tab clicks): Undo posts to the host, + Clear removes the card. Floated right in the header; the collapsible-panel header's + right padding keeps them clear of the corner Expand button. */ + .dv-actions { float: right; display: inline-flex; gap: 6px; } + .dv-btn { background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 1px 8px; font-size: 11px; + font-family: "Segoe UI", sans-serif; cursor: pointer; } + .dv-btn:hover { color: var(--fg); border-color: var(--accent); } a.file-link { color: var(--sky); text-decoration: none; border-bottom: 1px dotted color-mix(in srgb, var(--sky) 55%, transparent); cursor: pointer; } a.file-link:hover { color: var(--accent); border-bottom-color: var(--accent); } @@ -947,6 +1084,21 @@ function addEchoClamps() { window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file')); }); + // Interactive diff-card chips (delegated — survives transcript export, like the toggles). + // Clear just deletes the card from the DOM; Undo asks the host, which confirms before + // discarding anything. In an exported page Undo is a harmless no-op (no webview bridge). + document.addEventListener('click', function (e) { + const clear = e.target.closest('.dv-clear'); + if (clear) { + const panel = clear.closest('.panel'); + if (panel) panel.remove(); + return; + } + const undo = e.target.closest('.dv-undo'); + if (undo && window.chrome && window.chrome.webview) + window.chrome.webview.postMessage('undo-file:' + undo.getAttribute('data-file')); + }); + // --- drag hand-off: Chromium owns drags over the transcript surface, so XAML never sees // them. On dragenter we alert the host, which mounts its drop overlay over this WebView; // the OS then retargets the drag (and the drop, with real file paths) to that overlay. diff --git a/src/MandoCode.Desktop/Services/WinUiApprovalService.cs b/src/MandoCode.Desktop/Services/WinUiApprovalService.cs index 4d69eab..5659aa6 100644 --- a/src/MandoCode.Desktop/Services/WinUiApprovalService.cs +++ b/src/MandoCode.Desktop/Services/WinUiApprovalService.cs @@ -178,7 +178,10 @@ public async Task HandleCommandApprovalAsync(string command) Title = "Run this command?", CommandText = command, ToastSummary = $"Wants to run: {(command.Length > 48 ? command[..48] + "…" : command)}", - Options = options + Options = options, + // Non-covering bottom bar (like plan approval): the transcript stays readable — + // often the context right above IS why the agent wants to run this command. + BottomBar = true }; var (choice, instructions) = await PromptAllowingInstructionCancelAsync(request); diff --git a/src/MandoCode.Desktop/Services/WorkspaceDeltaTracker.cs b/src/MandoCode.Desktop/Services/WorkspaceDeltaTracker.cs new file mode 100644 index 0000000..6d5c886 --- /dev/null +++ b/src/MandoCode.Desktop/Services/WorkspaceDeltaTracker.cs @@ -0,0 +1,113 @@ +namespace MandoCode.Desktop.Services; + +/// +/// Decides what to tell the model about workspace changes made OUTSIDE the conversation +/// (external edits, terminal commits, branch switches, the undo button's checkouts). +/// Pure logic, no UI: ChatTabView feeds it git snapshots and watcher touches; it returns +/// note strings for the next message's preamble. Thread-safe — watcher threads record +/// touches while the UI thread captures and emits. +/// +/// Lifecycle: when a turn ends (or an undo rewrites files) +/// → when the next git snapshot lands → +/// at send time. While a capture is pending the stored baseline is +/// STALE (it predates the agent's own edits), so EmitDelta stays silent rather than +/// misattribute the agent's work to the outside world — silence over lies; anything real +/// is still reported one turn later once a fresh baseline lands. +/// +public sealed class WorkspaceDeltaTracker +{ + private readonly object _lock = new(); + private Dictionary? _baseline; // relPath → kind at last turn end + private string? _baselineBranch; + private string? _baselineOid; + private bool _capturePending; + private readonly HashSet _touched = new(StringComparer.OrdinalIgnoreCase); + + /// The current baseline no longer reflects reality (a turn just ended, or an + /// undo rewrote files) — recapture on the next snapshot; emit nothing until then. + public void MarkCapturePending() + { + lock (_lock) _capturePending = true; + } + + public void CaptureBaselineIfPending(GitBranchInfo? info) + { + lock (_lock) + { + if (!_capturePending) return; + CaptureLocked(info); + } + } + + /// A file was touched while the agent was idle. Content edits to files that are + /// ALREADY dirty don't move their git-status entry, so the snapshot diff alone can't see + /// them — this set fills that gap. + public void RecordTouch(string relPath) + { + lock (_lock) _touched.Add(relPath); + } + + /// Diffs the current snapshot against the baseline and re-baselines. Call at + /// send time; deliver the returned notes with the outgoing message. + public IReadOnlyList EmitDelta(GitBranchInfo? current) + { + lock (_lock) + { + // Pending capture = the baseline predates the agent's last edits. Diffing now + // would report the agent's own work as external. Stay silent this turn. + if (_capturePending) return Array.Empty(); + + if (_baseline == null || current == null) + { + CaptureLocked(current); // first send / non-git folder — nothing to compare + return Array.Empty(); + } + + var notes = new List(); + var branchChanged = _baselineBranch != null && current.Branch != _baselineBranch; + if (branchChanged) + notes.Add($"The git branch changed from '{_baselineBranch}' to '{current.Branch}'."); + + var cur = current.Changes.ToDictionary(c => c.RelPath, c => c.Kind, StringComparer.Ordinal); + var appeared = cur.Keys.Where(k => !_baseline.ContainsKey(k)); + var resolved = _baseline.Keys.Where(k => !cur.ContainsKey(k)) + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + var editedInPlace = _touched.Where(t => cur.ContainsKey(t) && _baseline.ContainsKey(t)); + + var changedOnDisk = appeared.Concat(editedInPlace).Distinct() + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + if (changedOnDisk.Count > 0) + notes.Add("Files changed on disk: " + JoinCapped(changedOnDisk)); + + if (resolved.Count > 0) + { + // HEAD movement disambiguates commit vs revert — but only when the branch + // didn't also change (a checkout moves HEAD without committing anything). + var headMoved = !string.IsNullOrEmpty(_baselineOid) + && current.Oid.Length > 0 && current.Oid != _baselineOid; + var phrasing = branchChanged + ? "Files that no longer have uncommitted changes after the branch change: " + : headMoved + ? "Files COMMITTED outside this conversation (a new commit exists): " + : "Files whose uncommitted changes were REVERTED/discarded outside this conversation: "; + notes.Add(phrasing + JoinCapped(resolved)); + } + + CaptureLocked(current); + return notes; + } + } + + private void CaptureLocked(GitBranchInfo? info) + { + _baseline = info?.Changes.ToDictionary(c => c.RelPath, c => c.Kind, StringComparer.Ordinal); + _baselineBranch = info?.Branch; + _baselineOid = info?.Oid; + _touched.Clear(); + _capturePending = false; + } + + private static string JoinCapped(List paths) => paths.Count <= 10 + ? string.Join(", ", paths) + : string.Join(", ", paths.Take(10)) + $" (+{paths.Count - 10} more)"; +} diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 77e9a7e..b31adb6 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -64,6 +64,29 @@ public void AddReaction(string cardId, string emoji, string snippet) public void RemoveReaction(string cardId, string emoji) => _pendingReactions.RemoveAll(r => r.CardId == cardId && r.Emoji == emoji); + /// Workspace events the model can't see happen (the user discarding its edits + /// via the undo button, files changing outside the app, external branch switches). Same + /// ride-along mechanism as reactions: folded into the next message's preamble so the + /// model's picture of the working tree stays true. Capped — if the queue somehow runs + /// away, the oldest facts are the most likely to be stale anyway. + private readonly List _pendingWorkspaceNotes = new(); + + public void NoteWorkspaceEvent(string note) + { + if (_pendingWorkspaceNotes.Count >= 30) _pendingWorkspaceNotes.RemoveAt(0); + _pendingWorkspaceNotes.Add(note); + } + + /// Queues a note describing a shell command the USER ran (`!cmd` / /command), + /// output capped so a build log can't flood the preamble. + private void NoteShellCommand(string cmd, bool failed, string output) + { + if (string.IsNullOrWhiteSpace(cmd)) return; + var snippet = output.Length > 400 ? output[..400] + "… [truncated]" : output; + NoteWorkspaceEvent( + $"The user ran a shell command themselves: `{cmd}` ({(failed ? "FAILED" : "succeeded")}). Output:\n{snippet}"); + } + private CancellationTokenSource? _requestCts; private bool _isProcessing; @@ -333,10 +356,14 @@ public async Task SubmitAsync(string input) { _transcript.Append(_html.UserEcho(input)); - // Shell escape: ! + // Shell escape: !. The card renders only in the transcript, which the model + // never sees — so queue a workspace note too, or a user-run `git commit` / build + // is invisible to the agent (it would have to re-discover the state itself). if (input.TrimStart().StartsWith('!')) { - await _shell.RunAsync(input.TrimStart()[1..].Trim()); + var cmd = input.TrimStart()[1..].Trim(); + var (failed, output) = await _shell.RunAsync(cmd); + NoteShellCommand(cmd, failed, output); return; } @@ -391,6 +418,20 @@ public async Task SubmitAsync(string input) _pendingReactions.Clear(); } + // Workspace changes the model didn't make and can't see. Framed as facts (not + // instructions) with an explicit staleness warning, so the model re-reads rather + // than trusting its memory of file contents. + if (_pendingWorkspaceNotes.Count > 0) + { + var notes = string.Join("\n", _pendingWorkspaceNotes.Select(n => "- " + n)); + processedInput = + "[Workspace changes since your last turn, made outside this conversation. " + + "Your memory of affected file contents may be stale — re-read before relying on it:]\n" + + notes + + "\n\n[Current request:]\n" + processedInput; + _pendingWorkspaceNotes.Clear(); + } + if (needsPlanning) { processedInput += "\n\n[system: this request looks multi-step. " + @@ -924,8 +965,11 @@ private async Task DispatchCommandAsync(string input) case "music-vol": HandleMusicVolume(rawArgs); return; case "command": - await _shell.RunAsync(rawArgs); + { + var (failed, output) = await _shell.RunAsync(rawArgs); + NoteShellCommand(rawArgs, failed, output); return; + } case "skills": ShowSkills();