diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index 6026c5b..81f9283 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -12,10 +12,11 @@ + - + + + + + + + + + + + + + + + + + + + + + + + @@ -168,6 +191,9 @@ + + + @@ -191,8 +217,8 @@ - + @@ -392,6 +418,9 @@ + + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 9885c4c..e418b9f 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using System.Text.Json; using MandoCode.Models; using MandoCode.Desktop.Services; @@ -10,6 +11,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; using Microsoft.UI.Xaml.Shapes; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -23,6 +25,13 @@ public sealed class CommandSuggestion public string Description { get; init; } = ""; } +/// Row model for the snapshot summarizer dropdown — a model name plus whether it's a cloud +/// model (which may spend tokens) or a local one (free). +public sealed record ModelChoice(string Name, bool IsCloud) +{ + public string Tag => IsCloud ? "cloud · uses tokens" : "local · free"; +} + /// Row model for diff lines shown in the approval overlay. public sealed class DiffLineVm { @@ -71,6 +80,16 @@ public sealed partial class MainWindow : Window private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher; private bool _snapshotsPanelOpen; + // Slide animation state for the snapshots panel. The column width is tweened per-frame off + // CompositionTarget.Rendering so the panel glides in/out instead of snapping. Width is always + // held in pixels during and after the tween (never star) so an interrupted open/close can read + // the current width and continue smoothly from wherever it is. + private readonly Stopwatch _snapAnimClock = new(); + private EventHandler? _snapAnimHandler; + private double _snapAnimFrom, _snapAnimTo; + private bool _snapAnimHideOnDone; + private const double SnapAnimDurationMs = 220; + /// /// Settings and MCP edit the app-global config, but they still need a controller to route /// through — it owns ConfigKeySetter, the MCP coordinator, and a transcript to report into. @@ -87,7 +106,7 @@ public MainWindow() // ONE window-level subscription to the static ThemeChanged event. Chat tabs must not // subscribe individually — the handler would outlive every closed tab and leak. ThemeManager.ThemeChanged += () => OnUi(ApplyThemeToAllTabs); - SettingsTabs.SelectedItem = Tab_Appearance; + SettingsTabs.SelectedItem = Tab_Connection; // the setup that matters most opens first ThemeList.ItemsSource = UiTheme.All.Select(t => new ThemeVm { Theme = t }).ToList(); ModelCombo.Loaded += (_, _) => ApplyModelComboTarget(); S_WindowOpacity.Value = ThemeManager.WindowOpacity * 100; @@ -227,8 +246,13 @@ private ChatTabEntry CreateChatTab() private string _currentPage = "chat"; private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat"); - private void NavSettings_Click(object sender, RoutedEventArgs e) => SwitchPage("settings"); - private void NavMcp_Click(object sender, RoutedEventArgs e) => SwitchPage("mcp"); + + // Settings/MCP act as toggles: clicking the one you're already on closes it and returns to the + // last active agent, rather than reloading the page in place. + private void NavSettings_Click(object sender, RoutedEventArgs e) + => SwitchPage(_currentPage == "settings" ? "chat" : "settings"); + private void NavMcp_Click(object sender, RoutedEventArgs e) + => SwitchPage(_currentPage == "mcp" ? "chat" : "mcp"); private void SwitchPage(string page) { @@ -238,6 +262,11 @@ private void SwitchPage(string page) SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed; McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed; + // Glide the full-screen page in from the rail side (translate + fade). Both run on the + // composition thread, so the whole page slides smoothly regardless of how much it holds. + if (page == "settings") SlideInPage(SettingsPage, SettingsPageTransform); + else if (page == "mcp") SlideInPage(McpPage, McpPageTransform); + // Every agent view stays loaded; only the selected one shows, and only on the chat page. // Collapsing rather than removing is what keeps each WebView2's transcript alive. foreach (var tab in _tabs) @@ -261,6 +290,39 @@ private void SwitchPage(string page) } } + /// Slides a full-screen page (Settings/MCP) into view from the rail side, with a short + /// fade. Translate and Opacity are independent animations, so this stays smooth on the + /// composition thread no matter how much the page contains. + private static void SlideInPage(UIElement page, TranslateTransform transform) + { + var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; + + var slide = new DoubleAnimation + { + From = -48, + To = 0, + Duration = new Duration(TimeSpan.FromMilliseconds(260)), + EasingFunction = ease, + }; + Storyboard.SetTarget(slide, transform); + Storyboard.SetTargetProperty(slide, "X"); + + var fade = new DoubleAnimation + { + From = 0, + To = 1, + Duration = new Duration(TimeSpan.FromMilliseconds(200)), + EasingFunction = ease, + }; + Storyboard.SetTarget(fade, page); + Storyboard.SetTargetProperty(fade, "Opacity"); + + var sb = new Storyboard(); + sb.Children.Add(slide); + sb.Children.Add(fade); + sb.Begin(); + } + private void RefreshNavIcons() { var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"]; @@ -294,18 +356,51 @@ private void NavSnapshots_Click(object sender, RoutedEventArgs e) private void OpenSnapshots() { _snapshotsPanelOpen = true; - SnapshotsColumn.Width = new GridLength(0.6, GridUnitType.Star); // ~37% of the content area SnapshotsPanel.Visibility = Visibility.Visible; PopulateSnapshots(); RefreshNavIcons(); + // Target ~37% of the content area (everything right of the 48px rail), matching the old + // 0.6* / 1* split. Computed in pixels at open time so the tween can drive the column. + double target = Math.Max(320, (Root.ActualWidth - 48) * 0.375); + AnimateSnapshotsColumn(target, hideOnDone: false); } private void CloseSnapshots() { _snapshotsPanelOpen = false; - SnapshotsColumn.Width = new GridLength(0); - SnapshotsPanel.Visibility = Visibility.Collapsed; RefreshNavIcons(); + AnimateSnapshotsColumn(0, hideOnDone: true); + } + + /// Tweens the snapshots column width to with an ease-out curve, + /// gliding the panel open or closed. Re-entrant: a click mid-slide retargets from the current + /// width rather than restarting from the edge. + private void AnimateSnapshotsColumn(double toPx, bool hideOnDone) + { + // Drop any in-flight tween so rapid toggles can't stack Rendering handlers. + if (_snapAnimHandler != null) CompositionTarget.Rendering -= _snapAnimHandler; + + _snapAnimFrom = SnapshotsColumn.Width.IsAbsolute ? SnapshotsColumn.Width.Value : 0; + _snapAnimTo = toPx; + _snapAnimHideOnDone = hideOnDone; + _snapAnimClock.Restart(); + + _snapAnimHandler = (_, _) => + { + double t = Math.Clamp(_snapAnimClock.Elapsed.TotalMilliseconds / SnapAnimDurationMs, 0, 1); + double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic + double w = _snapAnimFrom + (_snapAnimTo - _snapAnimFrom) * eased; + SnapshotsColumn.Width = new GridLength(w, GridUnitType.Pixel); + + if (t >= 1) + { + CompositionTarget.Rendering -= _snapAnimHandler; + _snapAnimHandler = null; + _snapAnimClock.Stop(); + if (_snapAnimHideOnDone) SnapshotsPanel.Visibility = Visibility.Collapsed; + } + }; + CompositionTarget.Rendering += _snapAnimHandler; } private void OnSnapshotsChanged() @@ -621,6 +716,10 @@ private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelect TabPanel_Limits.Visibility = s == Tab_Limits ? Visibility.Visible : Visibility.Collapsed; TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed; TabPanel_Appearance.Visibility = s == Tab_Appearance ? Visibility.Visible : Visibility.Collapsed; + + // Appearance is app-wide (a window property), not a per-agent setting, so "Make Default for + // New Agents" has nothing to save there — hide it on that tab to avoid a no-op button. + MakeDefaultButton.Visibility = s == Tab_Appearance ? Visibility.Collapsed : Visibility.Visible; } private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) diff --git a/src/MandoCode.Desktop/Services/ContextSnapshot.cs b/src/MandoCode.Desktop/Services/ContextSnapshot.cs index 94b3596..d610bc5 100644 --- a/src/MandoCode.Desktop/Services/ContextSnapshot.cs +++ b/src/MandoCode.Desktop/Services/ContextSnapshot.cs @@ -1,46 +1,40 @@ namespace MandoCode.Desktop.Services; /// -/// A captured "history point": the conversation as it stood the instant the user switched an -/// agent's model. Switching a model clears the live context (a different model mid-history is a -/// different conversation), so this is the salvaged copy — the user can re-import it into another -/// model later, or delete it. +/// A captured "history point": an LLM-written recap of a conversation, saved on demand. Switching an +/// agent's model clears the live context (a different model mid-history is a different conversation); +/// rather than auto-salvaging a deterministic dump, the user is offered the chance to snapshot that +/// conversation — summarized by a model of their choice. A snapshot is therefore always born with a +/// real ; there is no "light"/"un-enhanced" state. /// -/// Pure data, no UI types, because it is created on a background thread during the switch. -/// is the free deterministic summary captured immediately; -/// is the full serialized transcript, kept so a richer LLM summary can be -/// generated on demand later () without needing the original conversation to -/// still be alive. +/// Pure data, no UI types — it is created on a background thread while the summary is generated. /// public sealed class ContextSnapshot { public required int Id { get; init; } public required DateTimeOffset CapturedAt { get; init; } - /// The model whose conversation this summarizes. + /// The model whose conversation this recaps. public required string OriginModel { get; init; } - /// The model the user switched TO (context for why the snapshot exists). - public required string SwitchedToModel { get; init; } + /// The model that generated (may differ from the origin — the user + /// can summarize with a lighter/cheaper local model, or a stronger one). + public required string SummarizerModel { get; init; } - /// Deterministic recap (ported from the harness). Always present, capped small. - public required string LightRecap { get; init; } + /// The LLM-generated recap — the whole point of the snapshot, and what Import carries. + public required string Recap { get; init; } - /// Full serialized history, kept so an AI summary can be generated later. - public required string RawHistory { get; init; } + /// Optional user-given name. Null/empty when the user didn't name it (then the card + /// falls back to the origin model as its title). + public string? Name { get; init; } /// Conversation length (messages, excluding the system prompt). public required int MessageCount { get; init; } - /// LLM-generated recap, once "Enhance" has run. Null until then. - public string? AiRecap { get; set; } + // ---- display helpers for the snapshots panel ---- - /// "Light" until enhanced, then "AI". - public string Tag => AiRecap != null ? "AI" : "Light"; + /// Card title: the user's name if given, else the model that had the conversation. + public string DisplayTitle => string.IsNullOrWhiteSpace(Name) ? OriginModel : Name!; - /// The recap to re-import — the richer AI one if it exists, else the light one. - public string BestRecap => AiRecap ?? LightRecap; - - // ---- display helpers for the snapshots flyout ---- public string TimeLabel => CapturedAt.LocalDateTime.ToString("MMM d · h:mm tt"); } diff --git a/src/MandoCode.Desktop/Services/HistorySummarizer.cs b/src/MandoCode.Desktop/Services/HistorySummarizer.cs index 6f689da..38d82b9 100644 --- a/src/MandoCode.Desktop/Services/HistorySummarizer.cs +++ b/src/MandoCode.Desktop/Services/HistorySummarizer.cs @@ -4,16 +4,13 @@ namespace MandoCode.Desktop.Services; /// -/// Desktop port of AIService.SynthesizeHistorySummary, which is private in the pinned -/// harness. It walks a chat history and produces a compact recap. Fed by the public -/// AIService.GetHistoryAsync(), so it needs no harness change. +/// Flattens a chat history into a plain-text transcript, fed by the public +/// AIService.GetHistoryAsync(). Snapshots buffer this dump and hand it to +/// to summarize — the LLM does the recap, so nothing here truncates. /// -/// Two flavors: — truncated per line and capped overall, the instant/free -/// snapshot taken on every model switch — and — untruncated, stored alongside so -/// a richer LLM summary can be generated later without the original conversation still being live. -/// -/// Behavioural port: if the harness's original changes when the submodule pin is rolled, re-check -/// this against it (same class of "ported seam" as ChatController / WinUiApprovalService). +/// (The deterministic per-line/overall truncation this once did — a port of the harness's compaction +/// summary — was dropped when snapshots moved to LLM summaries: it kept the oldest turns and cut the +/// most recent, which is backwards for a resumption recap.) /// public static class HistorySummarizer { @@ -27,11 +24,7 @@ public static bool HasContent(IReadOnlyList history, int sta return false; } - /// Truncated, capped recap — mirrors the harness's compaction summary. - public static string Light(IReadOnlyList history, int startIndex = 1, int maxChars = 1500) - => Build(history, startIndex, lineMax: 180, maxChars: maxChars); - - /// Full untruncated dump — stored for a possible later AI enhancement. + /// Full untruncated dump — the text handed to the summarizer. public static string Full(IReadOnlyList history, int startIndex = 1) => Build(history, startIndex, lineMax: int.MaxValue, maxChars: int.MaxValue); diff --git a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs new file mode 100644 index 0000000..f2b98ce --- /dev/null +++ b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs @@ -0,0 +1,141 @@ +using System.Text; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Ollama; + +namespace MandoCode.Desktop.Services; + +/// +/// One-off LLM summarizer that turns a buffered conversation into a snapshot's recap. Builds a bare +/// Ollama kernel with no plugins, tools, filters, or shared history, so summarizing can never touch a +/// live agent's conversation or trigger a tool call. Fed the full untruncated history from +/// ChatController.PendingSnapshot; the result becomes . +/// +/// Summarizes the ENTIRE conversation via map-reduce: the history is chunked, each chunk is +/// summarized, then the partial summaries are reduced into one final recap. This mirrors the shape +/// of Semantic Kernel's ConversationSummaryPlugin (chunk → summarize → combine) but is +/// hand-rolled here so it needs no extra alpha package (Plugins.Core / TextChunker), and uses a +/// prompt tuned for coding transcripts rather than SK's generic one. If we later pull in +/// Plugins.Core, this is the natural swap point. +/// +/// Lives in the Desktop tree because the harness AIService exposes no one-shot completion. +/// +public static class SnapshotEnhancer +{ + // Chunk sizing. Kept modest so a lightweight local model comprehends each chunk well, while the + // per-conversation chunk COUNT is capped so a huge history can't fan out into dozens of calls — + // instead chunks grow coarser. Either way the whole conversation is covered, never truncated. + private const int MinChunkChars = 6000; // ~1.5k tokens — comfortable for small models + private const int MaxChunks = 16; // bounds the number of local calls on giant histories + + // A snapshot recap is Imported and silently prepended to ANOTHER model's next message, so the + // prompts frame it as a HANDOFF BRIEFING to an AI assistant — not a human-facing summary. They're + // domain-agnostic (coding, research Q&A, debugging, plain chat), weight the most recent turns + // (where the current state lives), preserve specifics verbatim, and emit PLAIN PROSE (the card + // renders the recap as plain text, so markdown/asterisks would leak). + private const string StyleRules = + " Write plain, dense prose addressed to the assistant (e.g. \"The user is building…\"). No " + + "markdown, headings, bullets, asterisks, or backticks. Preserve specifics VERBATIM — file " + + "paths, names, numbers, versions, URLs, identifiers, exact decisions. Make explicit what is " + + "already DONE versus still UNFINISHED, so the assistant knows what to work on next. Be " + + "self-contained: don't refer to \"the conversation above.\" Use only what's actually in the " + + "transcript — never invent or assume. Don't describe what the conversation was NOT about; " + + "capture what it WAS about."; + + private const string MapPrompt = + "Summarize this SEGMENT of a longer conversation as raw material for a later handoff. Capture " + + "the substantive content: what the user wants, key facts, answers, decisions, code, files, " + + "values, and errors, plus anything left open. State facts only — no guessing about other " + + "segments." + StyleRules; + + private const string ReducePrompt = + "Below are ordered segment summaries of one conversation. Merge them into a single briefing " + + "that will be handed to another AI assistant so it can continue this conversation seamlessly. " + + "Cover: what the user is trying to do, the key facts / decisions / code established so far, " + + "any preferences or constraints the user stated, and the immediate open thread or next step " + + "(including any unanswered question). Give extra weight to the most recent exchanges — that's " + + "where things currently stand." + StyleRules + " Keep it to a tight paragraph or two; length " + + "should match how much actually happened."; + + private const string SinglePrompt = + "You are writing a briefing that will be silently handed to another AI assistant so it can " + + "continue this conversation without missing a beat. From the transcript below, write what " + + "that assistant needs to know: what the user is trying to do, the key facts / answers / " + + "decisions / code established so far, any preferences or constraints the user stated, and the " + + "immediate open thread or next step (including any unanswered question). Give extra weight to " + + "the most recent exchanges — that's where things currently stand." + StyleRules + " Keep it " + + "to a tight paragraph or two; length should match how much actually happened."; + + /// Generates an AI recap of using the given Ollama model. + /// Throws on connection/model failure; the caller decides how to surface it. + public static async Task SummarizeAsync( + string endpoint, string model, string rawHistory, CancellationToken ct = default) + { + var kernel = Kernel.CreateBuilder() + .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) + .Build(); + + var chat = kernel.GetRequiredService(); + + var chunks = Chunk(rawHistory); + + // Short conversation — one pass, straight to a final-shaped recap. + if (chunks.Count <= 1) + return await SummarizeOneAsync(chat, kernel, SinglePrompt, rawHistory, ct); + + // Map: summarize each segment independently. + var partials = new List(chunks.Count); + for (int i = 0; i < chunks.Count; i++) + { + var part = await SummarizeOneAsync(chat, kernel, MapPrompt, chunks[i], ct); + if (!string.IsNullOrWhiteSpace(part)) + partials.Add($"Segment {i + 1}/{chunks.Count}:\n{part}"); + } + + if (partials.Count == 0) return ""; + + // Reduce: fold the segment summaries into one recap. + return await SummarizeOneAsync(chat, kernel, ReducePrompt, string.Join("\n\n", partials), ct); + } + + /// One chat round: system instruction + the text to summarize. Stateless — a fresh + /// history each call, so nothing leaks between chunks. + private static async Task SummarizeOneAsync( + IChatCompletionService chat, Kernel kernel, string instruction, string text, CancellationToken ct) + { + var history = new ChatHistory(); + history.AddSystemMessage(instruction); + history.AddUserMessage(text); + + // Low temperature — a recap should be faithful, not creative. + var settings = new OllamaPromptExecutionSettings { Temperature = 0.2f }; + + var result = await chat.GetChatMessageContentAsync(history, settings, kernel, ct); + return result.Content?.Trim() ?? ""; + } + + /// Splits the history into chunks on line boundaries. Chunk size grows if needed so the + /// count never exceeds — the entire conversation is always covered. + private static List Chunk(string history) + { + history = history?.Trim() ?? ""; + if (history.Length <= MinChunkChars) return new List { history }; + + // Grow the chunk size so a very large history still fits in MaxChunks pieces. + int chunkSize = Math.Max(MinChunkChars, (int)Math.Ceiling((double)history.Length / MaxChunks)); + + var chunks = new List(); + var sb = new StringBuilder(chunkSize + 256); + foreach (var line in history.Split('\n')) + { + if (sb.Length > 0 && sb.Length + line.Length + 1 > chunkSize) + { + chunks.Add(sb.ToString()); + sb.Clear(); + } + sb.Append(line).Append('\n'); + } + if (sb.Length > 0) chunks.Add(sb.ToString()); + return chunks; + } +} diff --git a/src/MandoCode.Desktop/Services/SnapshotStore.cs b/src/MandoCode.Desktop/Services/SnapshotStore.cs index c6769c7..8e24003 100644 --- a/src/MandoCode.Desktop/Services/SnapshotStore.cs +++ b/src/MandoCode.Desktop/Services/SnapshotStore.cs @@ -29,7 +29,8 @@ public int Count get { lock (_lock) return _items.Count; } } - public ContextSnapshot Add(string originModel, string switchedTo, string lightRecap, string rawHistory, int messageCount) + public ContextSnapshot Add(string originModel, string summarizerModel, string recap, int messageCount, + string? name = null) { ContextSnapshot snapshot; lock (_lock) @@ -39,10 +40,10 @@ public ContextSnapshot Add(string originModel, string switchedTo, string lightRe Id = ++_nextId, CapturedAt = DateTimeOffset.Now, OriginModel = originModel, - SwitchedToModel = switchedTo, - LightRecap = lightRecap, - RawHistory = rawHistory, + SummarizerModel = summarizerModel, + Recap = recap, MessageCount = messageCount, + Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim(), }; _items.Insert(0, snapshot); // newest first } diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index f5a8a1c..346dcc9 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -38,10 +38,12 @@ public sealed partial class ChatController private readonly McpCoordinator _mcp; private readonly SnapshotStore _snapshots; - /// Recap armed by "Import" — prepended (invisibly) to this agent's next message. - /// There is no public harness API to inject a message into a fresh conversation, so we ride it - /// along on the next send instead. - private string? _armedContext; + /// Recaps armed by "Import" — prepended (invisibly) to this agent's next message. + /// There is no public harness API to inject a message into a fresh conversation, so we ride them + /// along on the next send instead. Multiple imports ACCUMULATE (each is a distinct past + /// conversation), and all ride along together on the next send. + private readonly List _armedContexts = new(); + private readonly HashSet _armedSnapshotIds = new(); // dedupe: don't queue the same snapshot twice private CancellationTokenSource? _requestCts; private bool _isProcessing; @@ -342,15 +344,19 @@ public async Task SubmitAsync(string input) var needsPlanning = _taskPlanner.RequiresPlanning(input); var processedInput = ProcessFileReferences(input); - // An imported snapshot (from "Import" in the Snapshots panel) rides along ONCE, as - // background the model already knows — the user's echoed message stays their own text. - if (_armedContext is { Length: > 0 }) + // Imported snapshots (from "Import" in the Snapshots panel) ride along ONCE, as background + // the model already knows — the user's echoed message stays their own text. Multiple + // imports accumulate and are all sent together, each kept as a distinct recap. + if (_armedContexts.Count > 0) { + var noun = _armedContexts.Count == 1 ? "recap" : "recaps"; processedInput = - "[Imported context recap from a previous model. Treat it as background you " + - "already have; do not reply to it directly.]\n" + _armedContext + + $"[Imported context — {_armedContexts.Count} {noun} from previous conversations. " + + "Treat as background you already have; do not reply to it directly.]\n" + + string.Join("\n\n", _armedContexts) + "\n\n[Current request:]\n" + processedInput; - _armedContext = null; + _armedContexts.Clear(); + _armedSnapshotIds.Clear(); // a new batch can re-import the same snapshots next time } if (needsPlanning) @@ -1125,11 +1131,12 @@ private async Task HandleModelCommandAsync(string rawArgs) /// private async Task ApplyModelSwitchAsync(string modelTag) { - // Snapshot the outgoing conversation BEFORE anything clears it. ReinitializeAsync below - // wipes the live history (a different model mid-history is a different conversation), so - // this is the one chance to salvage it for a later re-import. + // Buffer the outgoing conversation BEFORE anything clears it. ReinitializeAsync below wipes + // the live history (a different model mid-history is a different conversation), so this is the + // one chance to grab it. It is NOT auto-saved — we offer the user a snapshot (summarized by a + // model of their choice) after the switch; if they ignore the offer, the buffer is discarded. var previousModel = _config.GetEffectiveModelName(); - var captured = await CaptureContextSnapshotAsync(previousModel, modelTag); + _pending = await BufferConversationAsync(previousModel); _config.ModelName = modelTag; _config.ModelPath = null; @@ -1155,55 +1162,119 @@ private async Task ApplyModelSwitchAsync(string modelTag) } _transcript.Append(_html.StatusChip(modelTag, "now active", "ok")); - _transcript.Append(captured - ? _html.StatusChip("Context cleared", "snapshot saved", "") - : _html.StatusChip("Context cleared", "new model starts fresh", "")); + + // Only mention the cleared context — and offer a snapshot — when there was actually a + // conversation to clear. Switching an empty chat has nothing to salvage, so stay quiet. + if (_pending != null) + { + _transcript.Append(_html.StatusChip("Context cleared", "create a snapshot?", "")); + SnapshotOfferChanged?.Invoke(); + } + StateChanged?.Invoke(); } - /// - /// Captures the outgoing conversation as a before a model switch - /// clears it. Returns false (and stores nothing) when there is nothing worth keeping. Never - /// throws — a failed snapshot must not block the switch. - /// - private async Task CaptureContextSnapshotAsync(string originModel, string switchedTo) + /// The outgoing conversation buffered on a model switch (or the live one, for a manual + /// snapshot), held only until the user creates a snapshot from it or ignores the offer. Pure + /// opt-in: it is NOT auto-saved, and is discarded on the next switch or when the app closes. + public sealed record PendingSnapshot(string OriginModel, string RawHistory, int MessageCount); + + private PendingSnapshot? _pending; + + /// The conversation currently on offer to snapshot, or null if there's nothing pending. + public PendingSnapshot? PendingOffer => _pending; + + /// Raised when appears or clears, so the tab can show/hide its + /// "create a snapshot?" card. Fires on the calling thread. + public event Action? SnapshotOfferChanged; + + /// Buffers the outgoing conversation before a switch clears it. Returns null (nothing + /// buffered) when there's nothing worth keeping. Never throws — must not block the switch. + private async Task BufferConversationAsync(string originModel) { try { var history = await _ai.GetHistoryAsync(); - if (!HistorySummarizer.HasContent(history)) return false; - - _snapshots.Add( - originModel, - switchedTo, - HistorySummarizer.Light(history), - HistorySummarizer.Full(history), - history.Count - 1); // exclude the system prompt at index 0 - return true; + if (!HistorySummarizer.HasContent(history)) return null; + // Full (untruncated) — this is only ever fed to the summarizer, never stored on a snapshot. + return new PendingSnapshot(originModel, HistorySummarizer.Full(history), history.Count - 1); } catch { - return false; + return null; } } - /// - /// Snapshots the current conversation on demand — without switching models or clearing it. - /// The "Take snapshot" tab action. Notes the outcome in the transcript. - /// - public async Task CaptureManualSnapshotAsync() + /// Offers to snapshot the CURRENT conversation on demand (the "Take snapshot" tab action), + /// without switching models or clearing it. Shows the create card, or notes there's nothing to save. + public async Task OfferManualSnapshotAsync() { - var captured = await CaptureContextSnapshotAsync(ModelName, ModelName); - _transcript.Append(captured - ? _html.StatusChip("Snapshot saved", "in Snapshots", "ok") - : _html.StatusChip("Nothing to snapshot", "start a conversation first", "warn")); + _pending = await BufferConversationAsync(ModelName); + if (_pending == null) + { + _transcript.Append(_html.StatusChip("Nothing to snapshot", "start a conversation first", "warn")); + return; + } + SnapshotOfferChanged?.Invoke(); + } + + /// Creates a snapshot from the pending conversation, summarized by + /// (the origin model, the user's favorite, or an explicit pick). + /// Born summarized — there is no light/un-enhanced state. Returns null on success, else an error. + public async Task CreateSnapshotAsync(string summarizerModel, string? name = null) + { + var pending = _pending; + if (pending == null) return "Nothing to snapshot."; + if (string.IsNullOrWhiteSpace(summarizerModel)) return "Pick a model to summarize with."; + + try + { + // The endpoint is shared (Ollama routes local and cloud models alike), so any installed + // model summarizes fine regardless of which model the chat is on. + var recap = await SnapshotEnhancer.SummarizeAsync( + _config.OllamaEndpoint, summarizerModel, pending.RawHistory); + + if (string.IsNullOrWhiteSpace(recap)) + return "The model returned an empty recap."; + + _snapshots.Add(pending.OriginModel, summarizerModel, recap, pending.MessageCount, name); + _pending = null; + SnapshotOfferChanged?.Invoke(); + var label = string.IsNullOrWhiteSpace(name) ? $"summarized by {summarizerModel}" : $"\"{name.Trim()}\""; + _transcript.Append(_html.StatusChip("Snapshot saved", label, "ok")); + return null; + } + catch (Exception ex) + { + return $"Snapshot failed: {ex.Message}"; + } + } + + /// Declines the pending snapshot offer — pure opt-in, so the buffered conversation is + /// discarded. + public void DismissSnapshotOffer() + { + if (_pending == null) return; + _pending = null; + SnapshotOfferChanged?.Invoke(); } /// Arms a snapshot's recap so it rides along (invisibly) with this agent's next message. public void ImportContext(ContextSnapshot snapshot) { - _armedContext = snapshot.BestRecap; - _transcript.Append(_html.StatusChip("Context imported", $"from {snapshot.OriginModel}", "")); + // Skip a snapshot that's already queued for the next send, so a double-click (or re-import) + // doesn't stack the same recap twice. + if (!_armedSnapshotIds.Add(snapshot.Id)) + { + _transcript.Append(_html.StatusChip("Already imported", $"{snapshot.DisplayTitle} is queued", "")); + return; + } + + // Accumulate — importing several snapshots stacks them, each labeled so the model can tell + // the distinct past conversations apart. They all ride along on the next send. + _armedContexts.Add($"From \"{snapshot.DisplayTitle}\":\n{snapshot.Recap}"); + // Show the snapshot's name when it has one, else the model it came from. + _transcript.Append(_html.StatusChip("Context imported", $"from {snapshot.DisplayTitle}", "")); } private async Task HandleLearnCommandAsync()