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
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,62 @@ recorded by the `MandoCode` submodule.

## [Unreleased]

### Changed
- **Assistant text always starts on its own line.** Inserting a reply at the cursor used to glue it
onto the tail of whatever line you were mid-way through. It now opens a new line first — unless
the cursor already sits at the start of one, so an empty note doesn't gain a blank first line.
Replacing a highlighted selection is unchanged: there you aimed at a specific span, and pushing
the replacement onto its own line would orphan the rest of that line.
- **The snapshot offer now reads as a card floating over the chat.** It was painted with the same
panel shade as the docked chrome, which sits within a few points of the transcript background in
most themes (Visual Studio Dark is `#252526` on `#1E1E1E`), so it blended into the conversation.
Both stages — the thin bar and the full name + model picker — now use a new raised surface plus an
accent edge. The shade is derived per theme from that theme's own accent rather than hand-picked,
so it carries the theme's character (grayscale in E-Ink Paper, navy in W98, phosphor green in
Phosphor Fwog) and new themes get one automatically. The tint eases off on a theme whose text
contrast can't afford it — Solarized Light, which already sat below AA on its own panel — and is
skipped entirely on a theme whose panel already reads as raised, which keeps W98's card the
period-correct white dialog on the silver desktop.

### Fixed
- **W98 chat prompts are readable again.** Your own prompts rendered in the theme's gold, which
resolves to a dark mustard `#806000` — 3.21:1 on a silver window, under the accessibility floor
and hard going for anyone with less-than-perfect sight. W98 prompts now use black window text
(11.5:1), which is the era-correct answer anyway; the silver bevelled frame already marks whose
turn it is. The "Show more" toggle on a clamped prompt got the same treatment: it sits on the teal
desktop rather than in the window, where the dim gray it used was 1.44:1 — effectively invisible —
and is now white underlined at 4.77:1. Other themes are untouched.

### Added
- **Undo for the notes assistant.** A gold undo arrow appears in the note header after the assistant
inserts or replaces text, putting the note back exactly as it was. Ctrl+Z can't do this job —
assigning the editor's text resets the TextBox's own undo history, so the one edit you *didn't*
type by hand was the one the control couldn't reverse, and a Replace could take a whole note with
it. The offer covers the assistant's last edit only and retires the moment you type, since
restoring the earlier buffer would otherwise discard whatever you'd written on top of it.
- **Chat backgrounds included in the box.** Settings → Appearance now offers a gallery of three
backgrounds that ship with MandoCode — **Golden Gate**, **Sequoia Trail**, and **Pismo Beach** —
so a fresh install has something to pick without hunting for a file. Click a tile to use it, click it again to turn it off; the active one is ringed and
named. Choosing your own image works exactly as before, and the two are interchangeable — a
tile is just a starting point, not a mode. The gallery is read from the release's
`Assets/images/backgrounds` folder at startup rather than listed in code, so a future release
adds one by dropping the file in. A **fresh install now opens on "Golden Gate"** at the usual 30%
opacity instead of a bare theme — first launch only, so nobody who has already set (or cleared) a
background is re-skinned by an update.
- **One-click snapshot from the tab header.** A camera button joins the folder and explorer icons
at the right of each agent's header, taking the same snapshot offer that lived two clicks deep
in the tab's "…" menu (which stays). It sits with the header's other *actions* rather than
beside the model label it captures, so it keeps a fixed position instead of sliding whenever
the model name changes length. On an empty conversation it answers with the usual "Nothing to
snapshot" chip rather than presenting a dead button.
- **History cards quote the agent's last reply.** A card showed the opening prompt and the last
thing you typed; it now adds the last thing the agent *said*, which is usually what you
actually remember a conversation by. The reply is flattened out of markdown (code fences,
headings, bullets and tables dropped; link text kept) and clipped to its first couple of
sentences, so the card doesn't grow — the opening line gives up a third row of wrapping to pay
for it. The two closing quotes are now labeled **you** and **reply** so it's clear which voice
is which. Conversations archived before this fill in on the first History open, alongside the
existing last-message backfill (one file read for both).
- **Agent callsigns.** A Settings → Behavior toggle (app-wide) names new agents from a
curated 500+ pool of handles — construct-crew, phreak, and cypher energy ("Morphy",
"Crunch", "Blazor", "Kaos") — drawn from a shuffled deck that doesn't repeat until it runs
Expand Down
104 changes: 104 additions & 0 deletions src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// The bundled-background gallery is discovered from a folder rather than declared in code, so the
/// file-naming convention IS the contract — these pin the rules documented in
/// Assets/images/backgrounds/README.md, which is what a future release adds images against.
/// </summary>
public class BuiltInBackgroundsTests : IDisposable
{
private readonly string _folder = Path.Combine(
Path.GetTempPath(), "mandocode-bg-tests-" + Guid.NewGuid().ToString("N"));

public BuiltInBackgroundsTests() => Directory.CreateDirectory(_folder);

public void Dispose()
{
try { Directory.Delete(_folder, recursive: true); } catch { }
}

private void Add(string fileName) => File.WriteAllText(Path.Combine(_folder, fileName), "x");

// ---- display names ---------------------------------------------------------------

[Theory]
[InlineData("01-nebula-drift.jpg", "Nebula Drift")]
[InlineData("02-violet-haze.png", "Violet Haze")]
[InlineData("10_deep_space.webp", "Deep Space")]
[InlineData("aurora.jpg", "Aurora")]
[InlineData("two words.png", "Two Words")]
public void DisplayNameFor_strips_the_ordering_prefix_and_titlecases(string file, string expected)
=> Assert.Equal(expected, BuiltInBackgrounds.DisplayNameFor(file));

[Fact]
public void DisplayNameFor_keeps_a_leading_number_that_is_part_of_the_name()
{
// No separator after the digits, so "1999" is the name — not an ordering prefix.
Assert.Equal("1999 Skyline", BuiltInBackgrounds.DisplayNameFor("1999 skyline.jpg"));
// A prefix with nothing after it must not clip the whole name away.
Assert.Equal("01", BuiltInBackgrounds.DisplayNameFor("01.jpg"));
}

// ---- discovery -------------------------------------------------------------------

[Fact]
public void DiscoverIn_returns_empty_for_a_missing_folder()
=> Assert.Empty(BuiltInBackgrounds.DiscoverIn(Path.Combine(_folder, "nope")));

[Fact]
public void DiscoverIn_returns_empty_when_the_folder_has_no_images()
{
Add("README.md");
Assert.Empty(BuiltInBackgrounds.DiscoverIn(_folder));
}

[Fact]
public void DiscoverIn_orders_by_file_name_so_the_numeric_prefix_controls_the_gallery()
{
Add("03-third.jpg");
Add("01-first.jpg");
Add("02-second.jpg");

Assert.Equal(
new[] { "First", "Second", "Third" },
BuiltInBackgrounds.DiscoverIn(_folder).Select(b => b.DisplayName));
}

[Fact]
public void DiscoverIn_takes_only_decodable_image_extensions()
{
Add("01-keep.jpg");
Add("02-keep.jpeg");
Add("03-keep.png");
Add("04-keep.webp");
Add("05-skip.txt");
Add("06-skip.bmp"); // the picker accepts it; BitmapImage thumbnails don't
Add("README.md");

var found = BuiltInBackgrounds.DiscoverIn(_folder);
Assert.Equal(4, found.Count);
Assert.All(found, b => Assert.StartsWith("Keep", b.DisplayName));
}

[Fact]
public void DiscoverIn_is_case_insensitive_about_extensions()
{
Add("01-shouty.JPG");
Assert.Single(BuiltInBackgrounds.DiscoverIn(_folder));
}

[Fact]
public void DiscoverIn_carries_the_file_name_as_the_durable_identity()
{
Add("01-nebula-drift.jpg");
var only = Assert.Single(BuiltInBackgrounds.DiscoverIn(_folder));

// The FILE NAME is what's persisted to mark the active tile — not the display name, which
// is derived and would change if the labeling rules ever did.
Assert.Equal("01-nebula-drift.jpg", only.FileName);
Assert.Equal(Path.Combine(_folder, "01-nebula-drift.jpg"), only.FullPath);
}
}
132 changes: 132 additions & 0 deletions src/MandoCode.Desktop.Tests/CardPreviewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

public class CardPreviewTests
{
// ---- Trim: the quoted user turns -------------------------------------------------

[Fact]
public void Trim_returns_null_for_nothing_to_show()
{
Assert.Null(CardPreview.Trim(null));
Assert.Null(CardPreview.Trim(""));
Assert.Null(CardPreview.Trim(" \n "));
}

[Fact]
public void Trim_keeps_a_short_message_whole_and_unellipsised()
=> Assert.Equal("checkout main and pull latest", CardPreview.Trim(" checkout main and pull latest "));

[Fact]
public void Trim_caps_a_long_message_with_an_ellipsis()
{
var clipped = CardPreview.Trim(new string('x', CardPreview.UserChars + 50));
Assert.Equal(CardPreview.UserChars + 1, clipped!.Length); // the cap plus the ellipsis
Assert.EndsWith("…", clipped);
}

// ---- ClipReply: the agent's answer ----------------------------------------------

[Fact]
public void ClipReply_returns_null_when_there_is_no_reply()
{
Assert.Null(CardPreview.ClipReply(null));
Assert.Null(CardPreview.ClipReply(" "));
}

[Fact]
public void ClipReply_keeps_the_first_two_sentences_and_drops_the_rest()
=> Assert.Equal(
"Already on main. The pull failed.",
CardPreview.ClipReply("Already on main. The pull failed. Git could not authenticate. Try gh."));

[Fact]
public void ClipReply_keeps_a_one_sentence_reply_whole()
=> Assert.Equal("Done — the branch is clean.", CardPreview.ClipReply("Done — the branch is clean."));

[Fact]
public void ClipReply_keeps_a_reply_with_no_terminator_at_all()
=> Assert.Equal("no trailing period here", CardPreview.ClipReply("no trailing period here"));

[Fact]
public void ClipReply_collapses_paragraphs_into_one_line()
=> Assert.Equal(
"First line. Second line.",
CardPreview.ClipReply("First line.\n\n Second line.\n"));

[Fact]
public void ClipReply_drops_fenced_code_blocks()
=> Assert.Equal(
"Here is the fix.",
CardPreview.ClipReply("Here is the fix.\n\n```csharp\nvar x = 1; // not card material\n```\n"));

[Fact]
public void ClipReply_drops_tilde_fences_too()
=> Assert.Equal("Ran it.", CardPreview.ClipReply("Ran it.\n~~~\ngit status\n~~~"));

[Fact]
public void ClipReply_returns_null_when_only_code_remains()
=> Assert.Null(CardPreview.ClipReply("```\ngit push --force\n```"));

[Fact]
public void ClipReply_strips_headings_quotes_and_bullets()
=> Assert.Equal(
"Summary Pulled main. Synced the submodule.",
CardPreview.ClipReply("## Summary\n\n- Pulled main.\n- Synced the submodule."));

[Fact]
public void ClipReply_strips_numbered_items_but_keeps_prose_that_starts_with_a_digit()
{
Assert.Equal("Fetch. Merge.", CardPreview.ClipReply("1. Fetch.\n2) Merge."));
Assert.Equal("27 files changed.", CardPreview.ClipReply("27 files changed."));
}

[Fact]
public void ClipReply_strips_task_list_checkboxes()
=> Assert.Equal("done thing", CardPreview.ClipReply("- [x] done thing"));

[Fact]
public void ClipReply_strips_emphasis_and_code_spans_but_keeps_underscores()
=> Assert.Equal(
"The LastMessage field on session_archive is set.",
CardPreview.ClipReply("The **LastMessage** field on `session_archive` is set."));

[Fact]
public void ClipReply_keeps_link_text_and_drops_the_target()
=> Assert.Equal(
"See MainWindow.xaml for the template.",
CardPreview.ClipReply("See [MainWindow.xaml](src/MandoCode.Desktop/MainWindow.xaml) for the template."));

[Fact]
public void ClipReply_drops_table_separator_rows()
=> Assert.Equal(
"Results: | file | lines |",
CardPreview.ClipReply("Results:\n\n| file | lines |\n|------|-------|"));

[Fact]
public void ClipReply_does_not_split_on_decimals_or_file_names()
=> Assert.Equal(
"Bumped to v1.2 in MainWindow.xaml.cs today. Second sentence.",
CardPreview.ClipReply("Bumped to v1.2 in MainWindow.xaml.cs today. Second sentence. Third."));

[Fact]
public void ClipReply_treats_a_terminator_cluster_as_one_sentence_end()
=> Assert.Equal("Wait, what?! It worked.", CardPreview.ClipReply("Wait, what?! It worked. Really."));

[Fact]
public void ClipReply_does_not_split_on_a_short_abbreviation()
=> Assert.Equal(
"Use gh, e.g. gh auth login, to sign in. Then pull.",
CardPreview.ClipReply("Use gh, e.g. gh auth login, to sign in. Then pull. And build."));

[Fact]
public void ClipReply_hard_caps_a_long_two_sentence_reply()
{
var wordy = new string('a', 200) + ". " + new string('b', 200) + ".";
var clipped = CardPreview.ClipReply(wordy);
Assert.Equal(CardPreview.ReplyChars + 1, clipped!.Length); // the cap plus the ellipsis
Assert.EndsWith("…", clipped);
}
}
7 changes: 7 additions & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
<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" />
<!-- The History card's quote-clipping rules: capping a user turn, and flattening the agent's
markdown reply down to a couple of sentences. Pure string work, which is exactly why it
lives in Services rather than in the panel's code-behind. -->
<Compile Include="..\MandoCode.Desktop\Services\CardPreview.cs" Link="src\CardPreview.cs" />
<!-- The bundled-background gallery. Discovered from a folder rather than declared in code, so
the file-naming convention is a real contract worth pinning; pure System.IO, no WinUI. -->
<Compile Include="..\MandoCode.Desktop\Services\BuiltInBackgrounds.cs" Link="src\BuiltInBackgrounds.cs" />
<Compile Include="..\MandoCode.Desktop\Services\TranscriptJournal.cs" Link="src\TranscriptJournal.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConversationLog.cs" Link="src\ConversationLog.cs" />
<!-- History's full-text matching + snippet extraction. Pure (text in, match out); the file reads
Expand Down
40 changes: 40 additions & 0 deletions src/MandoCode.Desktop.Tests/NoteTextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,44 @@ public void A_round_trip_through_the_editor_form_preserves_the_file()
Assert.Equal(original, NoteText.ToFileText(editorForm, newline));
}
}

// ---- LeadIn: assistant output always starts on its own line ----------------------

[Fact]
public void LeadIn_adds_a_newline_when_the_caret_sits_mid_line()
{
// The case this exists for: a reply landing on the tail of the line you were writing.
Assert.Equal("\n", NoteText.LeadIn("shopping list", 13));
Assert.Equal("\n", NoteText.LeadIn("one\rtwo", 7));
}

[Fact]
public void LeadIn_adds_nothing_at_the_very_start_of_a_note()
{
Assert.Equal("", NoteText.LeadIn("", 0));
Assert.Equal("", NoteText.LeadIn("already here", 0));
}

[Theory]
[InlineData("done\r", 5)] // editor form — WinUI holds newlines as bare CR
[InlineData("done\n", 5)] // file form, in case a buffer ever carries LF
[InlineData("done\r\n", 6)]
public void LeadIn_adds_nothing_when_the_caret_already_starts_a_line(string body, int at)
=> Assert.Equal("", NoteText.LeadIn(body, at));

[Fact]
public void LeadIn_does_not_double_up_on_a_blank_line()
{
// Caret after a blank line: there's already a line to write on, so forcing another newline
// would push assistant output down with a stray gap above it every time.
Assert.Equal("", NoteText.LeadIn("notes\r\r", 7));
}

[Fact]
public void LeadIn_tolerates_a_caret_past_the_end()
{
// Defensive: the caller clamps, but a LeadIn that threw would take the insert down with it.
Assert.Equal("\n", NoteText.LeadIn("abc", 99));
Assert.Equal("", NoteText.LeadIn("", 99));
}
}
8 changes: 8 additions & 0 deletions src/MandoCode.Desktop/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
<SolidColorBrush x:Key="MandoTextBrush" Color="{StaticResource MandoTextColor}" />
<SolidColorBrush x:Key="MandoDimBrush" Color="{StaticResource MandoDimColor}" />

<!-- Raised surface, for cards that FLOAT over the transcript and must not read as
more transcript. Panel sits only a few points from Background in most themes,
so a floating card painted with Panel disappears. Derived per theme from that
theme's own accent — see ThemeManager.Raised; this literal is the Mando Dark
value, used only until Initialize applies the saved theme. -->
<Color x:Key="MandoRaisedColor">#3E2753</Color>
<SolidColorBrush x:Key="MandoRaisedBrush" Color="{StaticResource MandoRaisedColor}" />

<!-- Replace the user's Windows accent color with MandoCode purple so
accent buttons, toggle switches, sliders, and selection highlights
match the brand on every machine -->
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading