Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ are visible until you actually open a second tab.
tab's snapshots. **Import** arms a snapshot so its recap rides along, invisibly, with the *active*
agent's next message, carrying the context into any model. The store is app-wide, so a snapshot
taken in one tab imports into a brand-new tab on a capable model. **Take snapshot** (tab options
menu) captures on demand without switching. The recap is a deterministic port of the harness's own
compaction summary (`HistorySummarizer`), fed by the public `AIService.GetHistoryAsync()` — no
submodule change. The full history is stored alongside each snapshot so a richer LLM summary can
be generated later without the original conversation still being live.
menu) captures on demand without switching. The recap is written by a summarizer model you pick
(`SnapshotEnhancer`, a tool-less Ollama kernel that map-reduces over the full history so nothing is
truncated), so a snapshot is always born with a real recap — there is no "light"/un-enhanced state.
- **Per-tab options menu.** The tab's `⋯` menu carries Rename, Take snapshot, Export transcript,
and Close. It replaces the bare close button — which, on the last remaining agent, was an `X`
you were not allowed to use; Close is now simply greyed out there.
Expand Down Expand Up @@ -77,6 +76,22 @@ are visible until you actually open a second tab.
- **Unread badges.** The History and Snapshots rail badges are now unread counts — items newer than
the last time you opened that panel — and clear when you open it, rather than showing a running
total. The "last seen" marks persist across launches.
- **Integrated terminal.** A sliding terminal panel (Ctrl+` toggles it, Ctrl+Shift+` maximizes)
runs a real shell through ConPTY, rendered with xterm.js inside WebView2 — no new native
dependencies. A shell picker (`ShellCatalog`) selects PowerShell/cmd/etc., and the terminal
opens in the active agent's project folder.
- **File explorer with git awareness.** Each agent has a collapsible file tree, kept live by a
`FileSystemWatcher`, alongside a **Changes** tab driven by `GitQuickStatus`: a branch chip,
per-file add/modify/delete status with dirty badges on files and folders in the tree, inline
diff cards, a one-click **commit**, and per-file **undo** (with confirmation). Tree items drag
into the input as `@`-references, and paths can be dropped onto the chat.
- **External-change awareness.** `WorkspaceDeltaTracker` notices when the working tree changed
outside the conversation — a commit, a revert, or a branch switch between your turns — and notes
it to the agent so its next reply reflects the repo as it actually is, not a stale picture.
- **Skills page + AI-assisted authoring.** A **Skills** sidebar page lists installed skills
(searchable, filterable, enabled per agent), installs new ones from a folder or a zip, and its
editor can **generate or refine** a skill body with a model you pick (`SkillAuthor`).
`SkillCoordinator` fans skill changes out to every open agent, mirroring `McpCoordinator`.
- **Branded app icon** across the exe, taskbar, and window title bar, plus a lightweight
unhandled-exception logger (`crash.log`) to speed up diagnosing native/COM failures.

Expand Down Expand Up @@ -165,12 +180,6 @@ are visible until you actually open a second tab.
- Each agent holds a live WebView2 (tens of MB). A retained transcript log would let background
agents defer creating one until first shown.
- Agent settings are session-scoped by design and are not restored on launch.
- **LLM-enhanced snapshots.** The snapshot data model reserves an AI recap (`AiRecap`; the `Tag`
flips `Light`→`AI`, and `BestRecap` prefers it), but there is no "Enhance" action yet. A clean
LLM summary needs a small public seam on `AIService` — a no-tools completion on a side history —
added at the next submodule pin roll; the existing side-channels either fire tools
(`ExecutePlanStepAsync`) or would corrupt the live conversation. The panel is already built, so
it's a button plus one method once the seam lands.
- **Summarize-at-restore.** The tail-brief restore fallback still excerpts the stored dialogue
verbatim rather than running `HistorySummarizer` over it — better coverage of long sessions is a
follow-up, at the cost of one LLM call on restore.
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ graph; `SessionManager` owns the set of them. The split matters:
|---|---|
| `AIService` (its conversation, its model), `ChatController`, `TaskPlannerService` | The `MandoCodeConfig` on disk — the **defaults** a new agent starts on |
| `MandoCodeConfig` clone, `ProjectRootAccessor`, `SkillLoader`, `FileAutocompleteProvider` | `McpClientManager` (one set of server processes) |
| `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager`, `TranscriptHtmlBuilder` |
| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore` |
| `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager` (static), `TranscriptHtmlBuilder`, `SpinnerService` |
| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `UiUpdateCheckService` |

Tabs default to `Agent 1`, `Agent 2`, … (the folder shows in the header); the number reuses the
lowest free slot, and a rename or folder change never overwrites it. Each tab's `⋯` options menu
Expand Down Expand Up @@ -179,7 +179,7 @@ within 24 hours.
approve / don't-ask-again / deny / new-instructions / cancel-plan semantics)
- propose_plan flow: plan table, execute/reject/cancel, per-step progress bar,
step-failure skip/cancel
- Slash commands with autocomplete: /help /clear /model /config /retry /learn
- Slash commands with autocomplete: /help /setup /clear /model /config /retry /learn
/copy /copy-code /skills /force-skill /mcp /mcp tools /mcp remove /mcp-reload
/music* /command /exit — plus `!cmd` shell escape and `@file` references
- Token tracking + per-response summaries, per agent
Expand All @@ -202,14 +202,24 @@ within 24 hours.
collapsible groups. Unnamed snapshots get an auto-generated, unique title
- Rail badges on History and Snapshots are unread counts that clear when you open
the panel (persisted), not running totals
- Sidebar: Settings and MCP as full-screen pages, acting on the selected agent
- Integrated terminal — a sliding panel (Ctrl+` / Ctrl+Shift+` to maximize) running a
real shell via ConPTY, rendered with xterm.js in WebView2; a shell picker chooses the
shell and it opens in the active agent's project folder
- Per-agent file explorer with git awareness — a live file tree (FileSystemWatcher) plus a
Changes tab (GitQuickStatus): branch chip, add/modify/delete status, dirty badges, inline
diff cards, one-click commit and per-file undo; tree items drag into the input as `@`-refs.
WorkspaceDeltaTracker notes commits/reverts/branch switches made outside the conversation
- Sidebar: Settings, MCP, and Skills as full-screen pages, acting on the selected agent
- Settings — the whole config as a native form (toggles, sliders, number boxes,
grouped Appearance/Connection/Generation/Behavior/Limits/Integrations); every
change is validated through the shared ConfigKeySetter, same as the CLI, and
applies to that agent alone. "Make Default for New Agents" saves it to disk
- MCP — live server list with status/tool counts; add/edit servers in a single
form modal with a Test button (isolated connection check + tool table preview).
Servers are one app-wide set; each agent chooses whether to attach their tools
- Skills — list installed skills (searchable, filterable, enabled per agent), install
from a folder or zip, and an editor that can generate or refine a skill body with a
model you pick (`SkillAuthor`); `SkillCoordinator` fans changes to every open agent
- Guided wizards, built on the approval-overlay select + text primitives:
- `/setup` — probe/start Ollama, change endpoint, pull a starter model with live
progress, model picker, cloud-auth check + sign-in walkthrough
Expand All @@ -218,7 +228,8 @@ within 24 hours.
- Branded application icon across the exe, taskbar, and window title bar
- Update check against this repo's GitHub Releases (24h throttle, fail-silent)

Not ported (yet): matrix easter eggs, terminal theme service (N/A).
Not ported: matrix easter eggs; the CLI's ANSI terminal-theme service (N/A — the app ships
its own integrated terminal instead, see above).

## License

Expand Down
31 changes: 31 additions & 0 deletions src/MandoCode.Desktop.Tests/AgentNamingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// Default agent labels reuse the lowest free "Agent N" slot, so closing a middle tab and opening a
/// new one refills the gap rather than climbing forever. User-renamed tabs are just taken names.
/// </summary>
public sealed class AgentNamingTests
{
[Fact]
public void FirstAgent_IsAgentOne()
=> Assert.Equal("Agent 1", AgentNaming.NextFreeName(Array.Empty<string?>()));

[Fact]
public void SequentialWhenAllTaken()
=> Assert.Equal("Agent 3", AgentNaming.NextFreeName(new[] { "Agent 1", "Agent 2" }));

[Fact]
public void ReusesLowestFreeSlot()
=> Assert.Equal("Agent 2", AgentNaming.NextFreeName(new[] { "Agent 1", "Agent 3" }));

[Fact]
public void RenamedTitlesAreJustTakenNames()
=> Assert.Equal("Agent 2", AgentNaming.NextFreeName(new[] { "Frontend", "Agent 1" }));

[Fact]
public void IgnoresNullAndEmptyTitles()
=> Assert.Equal("Agent 1", AgentNaming.NextFreeName(new string?[] { null, "" }));
}
64 changes: 64 additions & 0 deletions src/MandoCode.Desktop.Tests/ConfigCloningTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using MandoCode.Desktop.Services;
using MandoCode.Models;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// The config deep-clone every new agent starts from. The behaviour that MUST hold — warned about
/// in three places across the code yet untested until now — is that the JSON round-trip does not
/// leave <c>McpServers</c> with the case-SENSITIVE comparer System.Text.Json hands back: the clone's
/// <c>ValidateAndClamp</c> must rebuild it OrdinalIgnoreCase, or every MCP lookup in the clone
/// silently misses on a casing difference.
/// </summary>
public sealed class ConfigCloningTests
{
[Fact]
public void DeepClone_RebuildsMcpServers_CaseInsensitive()
{
var source = new MandoCodeConfig();
source.McpServers["Solana"] = new McpServerConfig { Command = "npx" };
// Sanity: a plain dict is case-sensitive, so the miscased lookup misses on the source.
Assert.False(source.McpServers.ContainsKey("solana"));

var clone = ConfigCloning.DeepClone(source);

Assert.True(clone.McpServers.ContainsKey("solana"));
Assert.True(clone.McpServers.ContainsKey("SOLANA"));
}

[Fact]
public void DeepClone_PreservesScalarValues()
{
var source = new MandoCodeConfig { ModelName = "qwen2.5-coder:14b", OllamaEndpoint = "http://example:1234" };

var clone = ConfigCloning.DeepClone(source);

Assert.Equal("qwen2.5-coder:14b", clone.ModelName);
Assert.Equal("http://example:1234", clone.OllamaEndpoint);
}

[Fact]
public void DeepClone_IsFullyDetached_MutatingCloneLeavesSourceAlone()
{
var source = new MandoCodeConfig();
source.McpServers["Solana"] = new McpServerConfig { Command = "npx" };

var clone = ConfigCloning.DeepClone(source);
clone.McpServers.Clear();
clone.McpServers["Other"] = new McpServerConfig { Command = "uvx" };

Assert.True(source.McpServers.ContainsKey("Solana"));
Assert.False(source.McpServers.ContainsKey("Other"));
}

[Fact]
public void DeepClone_AppliesValidateAndClamp_HealsBlankEndpoint()
{
var source = new MandoCodeConfig { OllamaEndpoint = " " };

var clone = ConfigCloning.DeepClone(source);

Assert.Equal("http://localhost:11434", clone.OllamaEndpoint);
}
}
58 changes: 58 additions & 0 deletions src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using MandoCode.Desktop.Services;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// The plain-text flattening handed to the snapshot summarizer. It must skip the system prompt at
/// index 0, label each turn by role, describe tool turns that carry no text, and produce an honest
/// placeholder when there is nothing to recap.
/// </summary>
public sealed class HistorySummarizerTests
{
private static ChatMessageContent Sys(string t) => new(AuthorRole.System, t);
private static ChatMessageContent Usr(string t) => new(AuthorRole.User, t);
private static ChatMessageContent Asst(string t) => new(AuthorRole.Assistant, t);

[Fact]
public void HasContent_False_WhenOnlySystemPrompt()
=> Assert.False(HistorySummarizer.HasContent(new List<ChatMessageContent> { Sys("you are helpful") }));

[Fact]
public void HasContent_True_WhenUserSpoke()
=> Assert.True(HistorySummarizer.HasContent(new List<ChatMessageContent> { Sys("sys"), Usr("hello") }));

[Fact]
public void Full_SkipsSystemPrompt_AndKeepsBothTurns()
{
var history = new List<ChatMessageContent> { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") };

var text = HistorySummarizer.Full(history);

Assert.DoesNotContain("SECRET SYSTEM", text);
Assert.Contains("hi there", text);
Assert.Contains("hey back", text);
}

[Fact]
public void Full_ReturnsPlaceholder_WhenNothingToSummarize()
=> Assert.Equal("(no prior activity captured)",
HistorySummarizer.Full(new List<ChatMessageContent> { Sys("sys") }));

[Fact]
public void Full_DescribesFunctionCall_WhenTextIsEmpty()
{
var toolTurn = new ChatMessageContent(AuthorRole.Assistant, content: null)
{
Items = { new FunctionCallContent("read_file", arguments: new KernelArguments { ["path"] = "Program.cs" }) }
};
var history = new List<ChatMessageContent> { Sys("sys"), toolTurn };

var text = HistorySummarizer.Full(history);

Assert.Contains("read_file", text);
Assert.Contains("path=Program.cs", text);
}
}
29 changes: 27 additions & 2 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,49 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<!-- The MandoCode harness is plain net8.0 (no Windows App SDK), so referencing it does NOT
pull the WinUI dependency this project exists to avoid. It supplies MandoCodeConfig (for the
config-clone tests) and Semantic Kernel's ChatMessageContent (for the HistorySummarizer
tests), and DiffModels transitively — which is why the direct DiffModels compile-include
below was removed. -->
<ItemGroup>
<ProjectReference Include="..\..\MandoCode\src\MandoCode\MandoCode.csproj" />
</ItemGroup>

<!-- The classes under test are pure logic with no WinUI dependencies, so we compile their
sources directly instead of referencing the WinUI app project (which would drag the
Windows App SDK into the test host). If these files ever grow UI dependencies, that's
a design regression this project exists to catch. -->
<ItemGroup>
<Compile Include="..\MandoCode.Desktop\Services\GitQuickStatus.cs" Link="src\GitQuickStatus.cs" />
<Compile Include="..\MandoCode.Desktop\Services\WorkspaceDeltaTracker.cs" Link="src\WorkspaceDeltaTracker.cs" />
<Compile Include="..\..\MandoCode\src\MandoCode\Models\DiffModels.cs" Link="src\DiffModels.cs" />

<!-- Session-archive/snapshot display derivations are pure logic (path parsing, placeholders);
the store classes come along because the entry types live beside them, but no test
instantiates a store (they hit fixed LocalAppData paths). -->
instantiates a store (they hit fixed LocalAppData paths). ProjectDisplay backs the
ProjectLabel/TimeLabel derivations shared by both entry types. -->
<Compile Include="..\MandoCode.Desktop\Services\ProjectDisplay.cs" Link="src\ProjectDisplay.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ContextSnapshot.cs" Link="src\ContextSnapshot.cs" />
<Compile Include="..\MandoCode.Desktop\Services\SnapshotNaming.cs" Link="src\SnapshotNaming.cs" />
<Compile Include="..\MandoCode.Desktop\Services\SessionArchiveStore.cs" Link="src\SessionArchiveStore.cs" />
<Compile Include="..\MandoCode.Desktop\Services\TranscriptJournal.cs" Link="src\TranscriptJournal.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
<Compile Include="..\MandoCode.Desktop\Services\SessionHistoryStore.cs" Link="src\SessionHistoryStore.cs" />

<!-- Pure logic extracted from session-coupled coordinators so it can be tested directly. -->
<Compile Include="..\MandoCode.Desktop\Services\ConfigCloning.cs" Link="src\ConfigCloning.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentNaming.cs" Link="src\AgentNaming.cs" />
<Compile Include="..\MandoCode.Desktop\Services\HistorySummarizer.cs" Link="src\HistorySummarizer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\RequestPreambleComposer.cs" Link="src\RequestPreambleComposer.cs" />

<!-- The streamed-response loop and the small WinUI-free collaborators it needs, so it can be
driven by a fake IAiService. TranscriptWriter/BusyStateService are pure event emitters;
ITranscriptHtml keeps the WinUI-coupled TranscriptHtmlBuilder out of the test host. -->
<Compile Include="..\MandoCode.Desktop\Services\IAiService.cs" Link="src\IAiService.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ITranscriptHtml.cs" Link="src\ITranscriptHtml.cs" />
<Compile Include="..\MandoCode.Desktop\Services\TranscriptWriter.cs" Link="src\TranscriptWriter.cs" />
<Compile Include="..\MandoCode.Desktop\Services\BusyStateService.cs" Link="src\BusyStateService.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\ResponseStreamer.cs" Link="src\ResponseStreamer.cs" />
</ItemGroup>

</Project>
Loading
Loading