diff --git a/CHANGELOG.md b/CHANGELOG.md index 428a7e2..b72929a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs b/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs new file mode 100644 index 0000000..dbe18ca --- /dev/null +++ b/src/MandoCode.Desktop.Tests/BuiltInBackgroundsTests.cs @@ -0,0 +1,104 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// 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. +/// +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); + } +} diff --git a/src/MandoCode.Desktop.Tests/CardPreviewTests.cs b/src/MandoCode.Desktop.Tests/CardPreviewTests.cs new file mode 100644 index 0000000..15d3625 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/CardPreviewTests.cs @@ -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); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 609e37e..41f4259 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -38,6 +38,13 @@ + + + + + #3E2753 + + diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg new file mode 100644 index 0000000..c9c5e62 Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/01-golden-gate.jpg differ diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg new file mode 100644 index 0000000..20cb21d Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/02-sequoia-trail.jpg differ diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg b/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg new file mode 100644 index 0000000..a9b2763 Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/backgrounds/03-pismo-beach.jpg differ diff --git a/src/MandoCode.Desktop/Assets/images/backgrounds/README.md b/src/MandoCode.Desktop/Assets/images/backgrounds/README.md new file mode 100644 index 0000000..166ffd6 --- /dev/null +++ b/src/MandoCode.Desktop/Assets/images/backgrounds/README.md @@ -0,0 +1,45 @@ +# Bundled chat backgrounds + +Images in this folder ship with the release and appear as tiles in **Settings → Appearance → Chat +background**, above "Choose image…". Users can still pick any file of their own; these are just the +ones that come in the box. + +Nothing here is registered in code. `Services/BuiltInBackgrounds.cs` reads the folder at startup, so +**adding an image is a file drop** — copy it in, and it's in the gallery on the next run. Removing one +is a delete; anyone already using it keeps their copy (see "How selection works" below). + +## Naming + + NN-kebab-case-name.jpg + +- `NN-` orders the gallery and is stripped from the label, so `01-` sorts first without showing. +- **`01-` is also the first-run default** — the image a brand-new install opens on, at the standard + 30% opacity (see `ThemeManager.ApplyFirstRunBackground`). Change the default by renumbering, not by + editing code. It applies on first launch only; existing users are never re-skinned. +- The rest becomes the tile's caption: `02-violet-nebula.jpg` → **"Violet Nebula"**. +- Extensions offered: `.jpg`, `.jpeg`, `.png`, `.webp`. Anything else in this folder is ignored. +- The file name is the durable identity — it's what's saved in `ui-settings.json` to mark the active + tile. **Renaming an image in a later release un-marks it** for anyone who had it selected (their + background keeps working; the tile just stops showing as active). Prefer adding over renaming. + +## Sizing + +These go into the installer, so every megabyte here is a megabyte every user downloads. + +- **1920×1080 is plenty** — the image is a backdrop behind text, drawn at whatever the window is, and + it renders at 30% opacity by default. +- **Aim for ≤600 KB each.** JPEG at quality ~80, or WebP, gets a 1920-wide render there comfortably. +- Favor **low-contrast, low-detail** images. Busy or bright ones fight the text, which is the whole + reason the opacity slider exists — an image that only works at 10% opacity isn't a good default. +- Thumbnails are generated at runtime (`DecodePixelWidth`), so don't add separate thumbnail files. + +## How selection works + +Picking a tile **copies** the image into the user's data folder (`%LOCALAPPDATA%\MandoCode.Desktop`) +as `chat-bg.`, exactly like picking your own file — the per-tab WebView serves it from there over +the `mandocode.userdata` host. + +That copy is why a user's background survives the app updating or being reinstalled underneath it, and +why an image dropped from a future release doesn't break anyone still using it. It also means an +updated image with the same file name will **not** replace the copy someone already has; ship it under +a new name if you want existing users to see the new version. diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css index a75e456..04278f4 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css @@ -115,8 +115,20 @@ 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; + /* The one place W98 must drop the gold "user's voice" convention: gold resolves to #806000 in + this palette, and dark mustard on a silver window is 3.21:1 — under the 4.5:1 AA floor and + genuinely hard to read. Black window text is both legible (11.5:1) and the era-correct answer; + the silver bevelled frame already marks whose turn it is, so color isn't carrying that meaning + here the way it does in every other theme. */ + html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px; color: var(--fg); border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + /* The clamp toggle is a SIBLING of the echo, so it lands on the teal desktop rather than inside + the silver window — same situation as the status lines above, and it takes the same treatment: + a brightened literal, because --dim on teal is 1.45:1 (effectively invisible). White underlined + is 4.77:1 and reads as a desktop icon label; the hover is a hue shift, since white has no + brightness left to gain. */ + html[data-win98] .ue-toggle { color: #FFFFFF; text-decoration: underline; } + html[data-win98] .ue-toggle:hover { color: #FFE082; } html[data-win98] .assistant { background: var(--bg); border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } html[data-win98] .assistant-label { diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs index 9a3aca5..4718e6d 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs @@ -187,4 +187,10 @@ private async void ClearTranscript() /// opt-in create card so the user can pick a summarizer model. public void TakeSnapshotManually() => _ = _controller.OfferManualSnapshotAsync(); + /// The header's camera button — the same offer as the tab menu's "Take snapshot", one + /// click away instead of two. No guard for an empty conversation: the offer itself answers that + /// with a "Nothing to snapshot" chip in the transcript, which teaches more than a dead button + /// would. + private void SnapshotButton_Click(object sender, RoutedEventArgs e) => TakeSnapshotManually(); + } diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index d49bd4e..6030d7b 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -109,6 +109,16 @@ ToolTipService.ToolTip="This tab's project folder"/> + + + + + - + MaxLines="2" TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/> + - + + + + + + + + + + + + + + + + + + +