diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c8c85..bba78db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,24 @@ for every approved plan. Desktop's version follows the engine generation, so it can still be sent straight through with One-shot it. - **Failed work can produce a revised remaining plan.** Completed steps stay settled, the proposed replacement is shown for review, and execution resumes only after approval. +- **Settings are split into global defaults and per-agent settings.** The rail's Settings page is + now Default Settings: the starting point every new agent is seeded from, and reachable with no + agent open. A gear in an agent's header, between the snapshot and folder icons, opens that + agent's own settings in a docked pane beside its conversation. Both surfaces are the same form + bound to different targets, so the two can never drift apart. +- **An agent keeps its own settings after it is closed and reopened.** Per-agent settings used to + live only in memory and were lost with the process. An agent now becomes independent the first + time its settings are saved, and is restored on those settings whether it comes back from a + relaunch or from the History panel. An agent that has never been configured keeps following the + defaults, so raising a default still reaches every agent you never touched. API keys are never + written to a per-agent file; they stay in the shared configuration and are supplied to each agent + in memory. +- **Settings apply when saved, not as you type.** Every control edits a pending copy, a Save button + reports how many changes are waiting, and closing the page or pane discards anything unsaved. + Values are still checked as they are entered, so a rejected number is refused where it is typed + rather than at save time. Two further actions on an agent's pane move settings between the two + scopes: Apply Global Defaults replaces an agent's settings with the defaults and lets it follow + them again, and Save to Global Defaults makes an agent's settings the starting point for new ones. ### Changed - **Completed turns now keep routine activity out of the conversation flow.** File operations, @@ -160,6 +178,12 @@ for every approved plan. Desktop's version follows the engine generation, so it a sticky header, or the suggestion list a field opens when it is filled. ### Fixed +- **The preview pane's open and attach buttons now work on web pages.** Both acted only on a + project file, so on a website they did nothing at all and gave no reason why. Open now hands the + page to the system's default browser and the attach button puts its address into the prompt, + while a preview of a project file still opens that file in its default application. A file + preview is served through an address that only resolves inside the app, so the two cases stay + deliberately distinct. Only ordinary web addresses are handed to the system. - **Per-tab model choices survive a restart.** Restoring a session initialized every tab at once, and a tab boots on the default model before moving onto its own saved one. Any workspace write during that window recorded the default over a tab's real model, so an agent you had switched @@ -181,10 +205,12 @@ for every approved plan. Desktop's version follows the engine generation, so it not conversation messages, so stale actions are not replayed into a restored session. ### Test coverage -303 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction +330 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction editing, dependent-step revision, checkpoint cards, Resume/Discard actions, semantic step outcomes, and truthful completion status. Browser coverage adds explicit tab targeting, frame identity, and -plan-card review content. The same workflows were also exercised with real models, including +plan-card review content. Settings coverage pins the rules a saved agent depends on: that a stored +agent configuration never contains an API key, that it is unaffected by app-wide changes made +elsewhere, and that an agent matching the defaults is treated as still following them. The same workflows were also exercised with real models, including closing the process between steps and resuming from the saved cursor. An opt-in smoke test drives a real WebView2 browser end to end: DOM reads, pointer and keyboard diff --git a/src/MandoCode.Desktop.Tests/AgentConfigStoreTests.cs b/src/MandoCode.Desktop.Tests/AgentConfigStoreTests.cs new file mode 100644 index 0000000..e6959f2 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentConfigStoreTests.cs @@ -0,0 +1,136 @@ +using MandoCode.Desktop.Services; +using MandoCode.Models; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The per-agent settings snapshot. Two claims here are load-bearing enough to be worth pinning, +/// and neither is visible from the file the user ends up with: +/// +/// 1. The API key is NOT in it. Per-agent settings mean one file per configured agent, so a +/// secret that rode along would be a secret in N places instead of one. +/// 2. The fingerprint is STABLE against app-wide churn. An agent is "configured" — and stops +/// inheriting the defaults — precisely when its fingerprint moves, so anything that shifts the +/// fingerprint without the user changing a setting silently orphans every open agent from the +/// defaults. Replacing the shared MCP server dictionary is exactly that kind of churn. +/// +public sealed class AgentConfigStoreTests +{ + [Fact] + public void Fingerprint_OmitsTheApiKey() + { + var config = new MandoCodeConfig { TavilyApiKey = "tvly-super-secret-value" }; + + var json = AgentConfigStore.Fingerprint(config); + + Assert.DoesNotContain("tvly-super-secret-value", json); + Assert.DoesNotContain("tavilyApiKey", json); + } + + [Fact] + public void Fingerprint_IgnoresTheApiKeyEntirely() + { + // Setting a key is not "configuring the agent" — it's app-wide, so it must not be what + // pushes an agent off the defaults. + var withoutKey = AgentConfigStore.Fingerprint(new MandoCodeConfig()); + var withKey = AgentConfigStore.Fingerprint(new MandoCodeConfig { TavilyApiKey = "tvly-abc" }); + + Assert.Equal(withoutKey, withKey); + } + + [Fact] + public void Fingerprint_IsUnchangedWhenTheSharedMcpServersAreReplaced() + { + var config = new MandoCodeConfig(); + config.McpServers["solana"] = new McpServerConfig { Command = "npx" }; + config.ValidateAndClamp(); + var before = AgentConfigStore.Fingerprint(config); + + // What editing the MCP page does to every live agent (ConfigCoordinator.SyncMcpServersToAgents): + // a whole new dictionary, with entries added in a different order. + var replacement = new MandoCodeConfig(); + replacement.McpServers["github"] = new McpServerConfig { Command = "npx" }; + replacement.McpServers["solana"] = new McpServerConfig { Command = "uvx" }; + replacement.ValidateAndClamp(); + config.McpServers = replacement.McpServers; + + Assert.Equal(before, AgentConfigStore.Fingerprint(config)); + } + + [Fact] + public void Fingerprint_MovesWhenARealSettingChanges() + { + var config = new MandoCodeConfig(); + var before = AgentConfigStore.Fingerprint(config); + + config.Temperature = config.Temperature + 0.25; + + Assert.NotEqual(before, AgentConfigStore.Fingerprint(config)); + } + + [Fact] + public void Fingerprint_IgnoresTheAgentsOwnName() + { + // AgentName is [JsonIgnore] on the harness type — it names one tab, not a setting. If it + // ever started serializing, every rename would masquerade as a settings change. + var config = new MandoCodeConfig(); + var before = AgentConfigStore.Fingerprint(config); + + config.AgentName = "Kernel"; + + Assert.Equal(before, AgentConfigStore.Fingerprint(config)); + } + + [Fact] + public void Fingerprint_MatchesTheDefaultsAfterCopyingThemOn() + { + // The equality the "inheriting agent" rule rests on: after CopyOnto, an agent is + // indistinguishable from the defaults, so AgentSession drops its saved file and goes back to + // tracking them. If these two ever stopped agreeing, "Match Global Defaults" would leave the + // agent permanently marked as configured. + var defaults = new MandoCodeConfig { Temperature = 0.35, MaxTokens = 4096 }; + var agent = new MandoCodeConfig { Temperature = 0.9, MaxTokens = 512, AgentName = "Kernel" }; + + ConfigCloning.CopyOnto(defaults, agent); + + Assert.Equal(AgentConfigStore.Fingerprint(defaults), AgentConfigStore.Fingerprint(agent)); + } + + [Fact] + public void Fingerprint_MatchesTheDefaultsForAFreshClone() + { + // Same rule at the other end: a brand-new agent is a clone of the defaults, so it must start + // out fingerprint-identical or every new agent would immediately look "configured". + var defaults = new MandoCodeConfig { Temperature = 0.35 }; + defaults.McpServers["Solana"] = new McpServerConfig { Command = "npx" }; + defaults.ValidateAndClamp(); + + var clone = ConfigCloning.DeepClone(defaults); + + Assert.Equal(AgentConfigStore.Fingerprint(defaults), AgentConfigStore.Fingerprint(clone)); + } + + [Fact] + public void Fingerprint_RoundTripsThroughTheConfigReader() + { + // The snapshot has to be loadable by the same reader clones use, or a restored agent comes + // back on the defaults with no sign anything went wrong. + var config = new MandoCodeConfig + { + ModelName = "qwen2.5-coder:14b", + OllamaEndpoint = "http://example:1234", + Temperature = 0.15, + EnableDiffApprovals = false, + }; + + var restored = ConfigCloning.Deserialize(AgentConfigStore.Fingerprint(config)); + + Assert.NotNull(restored); + Assert.Equal("qwen2.5-coder:14b", restored!.ModelName); + Assert.Equal("http://example:1234", restored.OllamaEndpoint); + Assert.Equal(0.15, restored.Temperature); + Assert.False(restored.EnableDiffApprovals); + Assert.Null(restored.TavilyApiKey); // stripped on the way out, injected on the way in + } +} diff --git a/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs b/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs index cd5d8d6..7af1fd4 100644 --- a/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs +++ b/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs @@ -27,6 +27,154 @@ public void DeepClone_RebuildsMcpServers_CaseInsensitive() Assert.True(clone.McpServers.ContainsKey("SOLANA")); } + // ---- CopyOnto: the in-place property copy behind both "Make Default for New Agents" + // (agent -> defaults) and "Match Global Defaults" (defaults -> agent). ---- + + [Fact] + public void CopyOnto_OverwritesTheTargetsSettings() + { + var source = new MandoCodeConfig { Temperature = 0.4, ModelName = "default-model" }; + var target = new MandoCodeConfig { Temperature = 0.9, ModelName = "agent-model" }; + + ConfigCloning.CopyOnto(source, target); + + Assert.Equal(0.4, target.Temperature); + Assert.Equal("default-model", target.ModelName); + } + + [Fact] + public void CopyOnto_NeverCarriesTheAgentName() + { + // AgentName is the tab's spoken identity, not a setting. Copying defaults (which have no + // name) onto an agent must not blank that agent out of its own system prompt. + var target = new MandoCodeConfig { AgentName = "Kernel" }; + + ConfigCloning.CopyOnto(new MandoCodeConfig(), target); + + Assert.Equal("Kernel", target.AgentName); + } + + [Fact] + public void CopyOnto_DoesNotStampAnAgentsNameOntoTheDefaults() + { + // The other direction: "Make Default for New Agents" must not leave one agent's callsign + // sitting on the config every future agent is seeded from. + var defaults = new MandoCodeConfig(); + + ConfigCloning.CopyOnto(new MandoCodeConfig { AgentName = "Kernel" }, defaults); + + Assert.Null(defaults.AgentName); + } + + [Fact] + public void CopyOnto_WritesThroughTheExistingInstance() + { + // An agent's AIService, SkillLoader and McpApprovalGate all captured this object at + // construction. Returning a new one instead of writing through leaves them on stale values. + var target = new MandoCodeConfig { MaxTokens = 1024 }; + var collaboratorsReference = target; + + ConfigCloning.CopyOnto(new MandoCodeConfig { MaxTokens = 8192 }, target); + + Assert.Equal(8192, collaboratorsReference.MaxTokens); + } + + [Fact] + public void CopyOnto_DoesNotShareCollectionsBetweenTheTwoConfigs() + { + // Reflection assigns reference types straight across; without the deep clone first, one + // side's later edit would silently be both sides'. + var source = new MandoCodeConfig(); + source.IgnoreDirectories.Add("dist"); + var target = new MandoCodeConfig(); + + ConfigCloning.CopyOnto(source, target); + target.IgnoreDirectories.Add("coverage"); + + Assert.DoesNotContain("coverage", source.IgnoreDirectories); + } + + [Fact] + public void CopyOnto_LeavesMcpServersCaseInsensitive() + { + var source = new MandoCodeConfig(); + source.McpServers["Solana"] = new McpServerConfig { Command = "npx" }; + var target = new MandoCodeConfig(); + + ConfigCloning.CopyOnto(source, target); + + Assert.True(target.McpServers.ContainsKey("solana")); + } + + // ---- DifferingKeys: the unsaved-changes count behind the settings form's Save button. ---- + + [Fact] + public void DifferingKeys_IsEmptyForAFreshClone() + { + // A just-opened form must not claim pending changes — its draft is a clone of the live config. + var live = new MandoCodeConfig { Temperature = 0.4, ModelName = "m" }; + live.McpServers["Solana"] = new McpServerConfig { Command = "npx" }; + live.ValidateAndClamp(); + + Assert.Empty(ConfigCloning.DifferingKeys(ConfigCloning.DeepClone(live), live)); + } + + [Fact] + public void DifferingKeys_NamesEachChangedKeyOnce() + { + var live = new MandoCodeConfig { Temperature = 0.4, MaxTokens = 1024 }; + var draft = ConfigCloning.DeepClone(live); + draft.Temperature = 0.9; + draft.MaxTokens = 2048; + + var keys = ConfigCloning.DifferingKeys(draft, live); + + Assert.Equal(2, keys.Count); + Assert.Contains("temperature", keys); + Assert.Contains("maxTokens", keys); + } + + [Fact] + public void DifferingKeys_ComparesCollectionsByContent() + { + // The draft holds its own List instance, so a by-reference comparison would report every + // collection as changed and the Save button would never go quiet. + var live = new MandoCodeConfig(); + live.IgnoreDirectories.Add("dist"); + var draft = ConfigCloning.DeepClone(live); + + Assert.DoesNotContain("ignoreDirectories", ConfigCloning.DifferingKeys(draft, live)); + + draft.IgnoreDirectories.Add("coverage"); + Assert.Contains("ignoreDirectories", ConfigCloning.DifferingKeys(draft, live)); + } + + [Fact] + public void DifferingKeys_HonoursTheIgnoreList() + { + // mcpServers is app-wide and edited on its own page, so the settings form excludes it — + // otherwise an MCP edit made while the form sat open would show up as the user's pending change. + var live = new MandoCodeConfig(); + var draft = ConfigCloning.DeepClone(live); + draft.McpServers["github"] = new McpServerConfig { Command = "npx" }; + + Assert.Contains("mcpServers", ConfigCloning.DifferingKeys(draft, live)); + Assert.Empty(ConfigCloning.DifferingKeys(draft, live, "mcpServers")); + } + + [Fact] + public void DifferingKeys_IsEmptyAfterCopyOnto() + { + // Save commits the draft with CopyOnto, then the form re-clones — which must leave it clean. + var live = new MandoCodeConfig { Temperature = 0.4 }; + var draft = ConfigCloning.DeepClone(live); + draft.Temperature = 0.9; + + ConfigCloning.CopyOnto(draft, live); + + Assert.Empty(ConfigCloning.DifferingKeys(draft, live)); + } + [Fact] public void DeepClone_PreservesScalarValues() { diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 6846969..ac3d4c8 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -78,6 +78,10 @@ + + diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs index a76e313..9255e8a 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs @@ -358,6 +358,7 @@ private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e) { if (_explorerOpen) SizeExplorer(); if (_previewOpen) SizePreview(); + if (_agentSettingsOpen) SizeAgentSettings(); } private void SizeExplorer() @@ -645,15 +646,66 @@ private async Task ConfirmOverwriteChangedFileAsync() return await dialog.ShowAsync() == ContentDialogResult.Primary; } + /// + /// What the preview header's "@" and "open externally" buttons act on. + /// + /// A browser tab backed by a PROJECT FILE resolves to that file, not to the URL on screen: a + /// file preview is served through a virtual host mapping (https://<PreviewBrowserHost>/...) + /// that exists only inside this WebView, so handing that URL to the system browser or to the + /// model would name something neither can reach. + /// + /// A tab on a real WEBSITE has no file (NavigateBrowserTabAsync nulls FilePath for it), which + /// is why both buttons used to do nothing at all there — the null guard swallowed the click + /// silently. It resolves to the live URL instead. + /// + private (string? Target, bool IsUrl) CurrentPreviewTarget() + { + if (_previewPath != null) return (_previewPath, false); + if (!_browserPreview || _selectedBrowserTab is not { Closed: false } tab) return (null, false); + + var source = tab.View.CoreWebView2?.Source; + return IsLaunchableUrl(source) ? (source, true) : (null, false); + } + + /// + /// Only http(s) is ever handed to the shell or pasted into the prompt. A WebView can be sitting + /// on about:blank, data:, javascript: or a custom scheme, and ShellExecute on an arbitrary + /// scheme launches whatever protocol handler is registered for it — not something a preview + /// button should be able to reach. + /// + private static bool IsLaunchableUrl(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + /// Puts the previewed file (as an @token) or web page (as its URL) into the prompt. private void PreviewAttach_Click(object sender, RoutedEventArgs e) { - if (_previewPath != null) InsertFileTokens(new[] { _previewPath }); + var (target, isUrl) = CurrentPreviewTarget(); + if (target == null) + { + _transcript.Append(_html.Warn("Nothing to attach \u2014 this preview has no project file or web address yet.")); + return; + } + + if (isUrl) InsertAtCaret(target + " "); + else InsertFileTokens(new[] { target }); } + /// Hands the previewed file to its default application, or the previewed page to the + /// system's default browser. private void PreviewOpenExternal_Click(object sender, RoutedEventArgs e) { - if (_previewPath != null && ShellOpen.Try(_previewPath) is { } ex) - _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}")); + var (target, isUrl) = CurrentPreviewTarget(); + if (target == null) + { + // Never silently: a button that does nothing on click reads as broken, which is exactly + // how the website case behaved before. + _transcript.Append(_html.Warn("Nothing to open \u2014 this preview has no project file or web address yet.")); + return; + } + + if (ShellOpen.Try(target) is { } ex) + _transcript.Append(_html.Warn($"Couldn't open {(isUrl ? "page" : "file")}: {ex.Message}")); } /// Agent writes can happen in several small operations. Reload once at turn end so diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Settings.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Settings.cs new file mode 100644 index 0000000..7bdca19 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Settings.cs @@ -0,0 +1,127 @@ +using MandoCode.Desktop.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; + +// ChatTabView lives in MandoCode.Desktop (not .Controls) despite its file sitting under Controls/ — +// match its other partials or this one silently becomes a separate, unused class. +namespace MandoCode.Desktop; + +/// +/// The per-agent settings pane — the gear in this tab's header, between the snapshot and folder +/// buttons. Hosts the SAME the rail's Settings page does, bound to this +/// agent instead of to the global defaults, so the two surfaces can never drift apart. +/// +/// Docked rather than overlaid, like the file explorer: opening it narrows the transcript instead +/// of covering the conversation you are changing settings for. +/// +public sealed partial class ChatTabView +{ + private bool _agentSettingsOpen; + + /// The form is created with the tab but only subscribed to on first open — and only + /// once. Re-subscribing on every open would stack duplicate handlers and fire UpdateHeader N + /// times per edit. + private bool _agentSettingsWired; + + /// Whether this tab's settings pane is showing. The state is per-tab, so switching + /// tabs doesn't carry one agent's open pane onto another. + public bool AgentSettingsOpen => _agentSettingsOpen; + + private void AgentSettingsButton_Click(object sender, RoutedEventArgs e) => + ToggleAgentSettings(!_agentSettingsOpen); + + private void AgentSettingsClose_Click(object sender, RoutedEventArgs e) => ToggleAgentSettings(false); + + private void ToggleAgentSettings(bool open) + { + if (open == _agentSettingsOpen) return; + _agentSettingsOpen = open; + + AgentSettingsPanel.Visibility = open ? Visibility.Visible : Visibility.Collapsed; + AgentSettingsSplitter.Visibility = open ? Visibility.Visible : Visibility.Collapsed; + + if (open) + { + // Re-bind on every open rather than once at construction: the agent's config moves + // underneath this pane (a model switch from the header, /config set in the chat, "Match + // Global Defaults"), and the form must show what is true now. + if (!_agentSettingsWired) + { + _agentSettingsWired = true; + // A model or endpoint change from in here has to reach the header, which shows both. + // (The guided wizard is NOT wired here — it configures the app, so it lives on the + // rail's Default Settings page.) + AgentSettingsForm.SettingsChanged += UpdateHeader; + } + + AgentSettingsForm.Bind(new AgentSettingsScope( + Session, App.Services.GetRequiredService())); + AgentSettingsTitle.Text = $"{Session.Title} — settings"; + _ = AgentSettingsForm.RefreshModelsAsync(); + SizeAgentSettings(); + } + + UpdateHeaderButtonStates(); + } + + /// Keeps the pane a sensible share of the tab, clamped so the form stays usable on a + /// small window and doesn't eat half a wide one. A width the user has dragged to wins. + private void SizeAgentSettings() + { + var w = ChatRoot.ActualWidth; + if (w <= 0) return; + var target = _agentSettingsUserWidth ?? Math.Clamp(w * 0.30, 340, 520); + AgentSettingsPanel.Width = Math.Clamp(target, MinAgentSettingsWidth, MaxAgentSettingsWidth()); + } + + private const double MinAgentSettingsWidth = 320; + + /// Leaves room for the transcript and for whatever else is already docked, so opening + /// the explorer and this pane together can't squeeze the conversation to nothing. + private double MaxAgentSettingsWidth() => Math.Max(MinAgentSettingsWidth, + ChatRoot.ActualWidth + - (_explorerOpen ? ExplorerPanel.ActualWidth : 0) + - (_previewOpen ? PreviewPanel.ActualWidth : 0) + - 320); + + // --- splitter drag (same pointer-capture pattern as the explorer's) --- + + private double? _agentSettingsUserWidth; // set on first drag; SizeAgentSettings defers to it + private bool _draggingAgentSettings; + private double _agentSettingsDragStartWidth; + private double _agentSettingsDragStartX; + + private void AgentSettingsSplitter_PointerPressed(object sender, PointerRoutedEventArgs e) + { + _draggingAgentSettings = true; + _agentSettingsDragStartWidth = AgentSettingsPanel.ActualWidth; + _agentSettingsDragStartX = e.GetCurrentPoint(ChatRoot).Position.X; // stable frame while the grip moves + ((UIElement)sender).CapturePointer(e.Pointer); + } + + private void AgentSettingsSplitter_PointerMoved(object sender, PointerRoutedEventArgs e) + { + if (!_draggingAgentSettings) return; + // Dragging left grows the pane; right shrinks it. + var delta = e.GetCurrentPoint(ChatRoot).Position.X - _agentSettingsDragStartX; + var next = Math.Clamp(_agentSettingsDragStartWidth - delta, MinAgentSettingsWidth, MaxAgentSettingsWidth()); + AgentSettingsPanel.Width = next; + _agentSettingsUserWidth = next; + } + + private void AgentSettingsSplitter_PointerReleased(object sender, PointerRoutedEventArgs e) + { + if (!_draggingAgentSettings) return; + _draggingAgentSettings = false; + ((UIElement)sender).ReleasePointerCapture(e.Pointer); + } + + /// Tints the gear while its pane is open, the same signal the explorer button gives. + private void UpdateHeaderButtonStates() + { + AgentSettingsButton.Foreground = _agentSettingsOpen + ? (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["MandoAccentBrush"] + : (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["MandoTextBrush"]; + } +} diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index c183a2f..28fb9db 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -122,6 +122,14 @@ ToolTipService.ToolTip="Snapshot this conversation's context"> + + + ToolTipService.ToolTip="Attach this file to the chat prompt (a web page attaches as its URL)"> + ToolTipService.ToolTip="Open this file in its default application, or this page in your default browser"> @@ -445,6 +455,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index 1ee13ff..1b5acfe 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -139,7 +139,15 @@ private void OnTranscriptActivityCompleted() => OnUi(() => private void OnAgentTitleChanged(string _) => OnUi(UpdateHeader); private void OnControllerStateChanged() => OnUi(UpdateHeader); private void OnPlanProgress(int done, int total, bool active) => OnUi(() => UpdatePlanProgress(done, total, active)); - private void OnSetupNeeded() => OnUi(() => SetupRequested?.Invoke()); + /// + /// /config, and first-run "not connected", want the settings of THIS agent — so they open this + /// tab's own pane rather than the rail page, which now holds the defaults for future agents. + /// + private void OnSetupNeeded() => OnUi(() => + { + SetupRequested?.Invoke(); // brings the chat page forward if a rail page is showing + ToggleAgentSettings(true); + }); private void OnMcpEditorRequested(string? name) => OnUi(() => McpEditorRequested?.Invoke(name)); private void OnClipboardCopy(string text) => OnUi(() => ClipboardCopyRequested?.Invoke(text)); private void OnExitRequested() => OnUi(() => ExitRequested?.Invoke()); diff --git a/src/MandoCode.Desktop/Controls/SettingsForm.xaml b/src/MandoCode.Desktop/Controls/SettingsForm.xaml new file mode 100644 index 0000000..a2260e0 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/SettingsForm.xaml @@ -0,0 +1,320 @@ + + + + + + 680 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + cloud + off + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/Controls/SettingsForm.xaml.cs b/src/MandoCode.Desktop/Controls/SettingsForm.xaml.cs new file mode 100644 index 0000000..239fd96 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/SettingsForm.xaml.cs @@ -0,0 +1,620 @@ +using MandoCode.Desktop.Services; +using MandoCode.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace MandoCode.Desktop.Controls; + +/// +/// The settings form, hosted twice: on the rail's Settings page bound to the global defaults, and +/// in the pane behind an agent's gear icon bound to that agent. is the +/// only difference between the two — every control, validation rule and status message is shared, +/// which is the point of the control existing. +/// +/// NOTHING HERE APPLIES AS YOU CHANGE IT. Every control edits , a detached +/// clone of the target config; Save commits the whole draft through the scope, and abandoning the +/// form (closing the pane, leaving the page) simply drops it. Values are still VALIDATED as they +/// are entered, through ConfigKeySetter — the same engine the CLI's /config set uses — so a bad +/// value is rejected where it is typed rather than at save time. +/// +/// The two app-wide settings ride along and appear on the defaults scope only: the Tavily key (a +/// secret, so it lives in exactly one file) and agent callsigns (not a config key at all, hence +/// ). +/// +public sealed partial class SettingsForm : UserControl +{ + /// Guards the write-back: populating the controls fires their change events, and an + /// unguarded population would stage every value it just read. + private bool _loading; + + private ISettingsScope? _scope; + + /// The edit buffer. Cloned from the scope's config on every bind/reload, diffed + /// against it to drive the Save button, and copied over it on Save. + private MandoCodeConfig? _draft; + + /// Callsigns live in PanelState rather than in the config, so they can't ride in the + /// draft — but they're on this form and must obey the same save-or-lose rule. + private bool _draftCallsigns; + + /// The model the picker should be showing. Held separately because the list arrives + /// asynchronously and can replace the ItemsSource after the selection was set. + private string _modelComboTarget = ""; + + /// Widest the form's content may get, matching the FormContentWidth XAML resource. + /// Past this, lines get too long to scan comfortably on the full-width defaults page. + private const double ContentWidthCap = 680; + + public SettingsForm() + { + InitializeComponent(); + SettingsTabs.SelectedItem = Tab_Model; // the setup that matters most opens first + ModelCombo.Loaded += (_, _) => ApplyModelComboTarget(); + SizeChanged += (_, _) => ApplyContentWidth(); + } + + /// + /// Sizes the action panel to min(available, cap), left-aligned — which XAML alone can't express. + /// Stretch plus MaxWidth centres the panel, and HorizontalAlignment Left makes a StackPanel + /// shrink-wrap to its widest child, either of which stops Save from being the full-width primary + /// it is meant to be. In the agent pane (narrower than the cap) this simply fills the pane. + /// + private void ApplyContentWidth() + { + if (ActualWidth <= 0) return; + ActionPanel.Width = Math.Min(ActualWidth, ContentWidthCap); + } + + /// Raised when the form wants its host to run the guided /setup wizard — it renders + /// into a chat transcript, which is the host's business, not the form's. + public event Action? SetupWizardRequested; + + /// Raised after a SAVE the host may need to reflect elsewhere (an agent's header, the + /// tab strip). Not raised for staged edits — nothing has happened yet. + public event Action? SettingsChanged; + + /// Raised after the app-wide Tavily key is saved, so the host can mirror it into every + /// live agent's in-memory config. + public event Action? SecretsChanged; + + /// True when the form is holding edits the user hasn't saved. + public bool HasUnsavedChanges => SaveButton.IsEnabled; + + /// + /// Points the form at what it should edit and repopulates it, discarding any draft in progress. + /// Hosts re-bind whenever their surface opens, so the form always reflects the live config. + /// + public void Bind(ISettingsScope scope) + { + _scope = scope; + + var isAgent = scope is AgentSettingsScope; + var appWide = scope.ShowsAppWideSettings; + + // App-wide settings appear once, on the page that owns app-wide things. On an agent's pane + // they would read as per-agent settings they are not. + S_AgentCallsigns.Visibility = appWide ? Visibility.Visible : Visibility.Collapsed; + TavilySection.Visibility = appWide ? Visibility.Visible : Visibility.Collapsed; + TavilyElsewhereNote.Visibility = appWide ? Visibility.Collapsed : Visibility.Visible; + // The whole Connection block is defaults-only: an agent's endpoint is set at launch and its + // model comes from its header dropdown, so neither belongs here. The guided wizard sits in + // that block too — it configures the app (it ends with SaveDefaultsFrom). + ConnectionSection.Visibility = appWide ? Visibility.Visible : Visibility.Collapsed; + + ApplyDefaultsButton.Visibility = isAgent ? Visibility.Visible : Visibility.Collapsed; + SaveToDefaultsButton.Visibility = isAgent ? Visibility.Visible : Visibility.Collapsed; + ResetFactoryButton.Visibility = isAgent ? Visibility.Collapsed : Visibility.Visible; + + ContextLengthNote.Text = isAgent + ? "Sent with every request (num_ctx), so once saved it applies from this agent's next " + + "message. Auto-sized to the model's hardware tier when you switch models; set it here to override." + : "Sent with every request (num_ctx). New agents start here, and each one re-sizes it to " + + "the model's hardware tier when its model changes."; + + Reload(); + } + + /// + /// Fetches the pulled-model list. Deliberately NOT part of : the defaults form + /// is bound in the window constructor, and probing Ollama from there would put a network call on + /// the startup path for a page the user may never open. Hosts call this when their surface + /// actually becomes visible. + /// + public Task RefreshModelsAsync() => RefreshModelListAsync(); + + /// Throws away any draft and repopulates every control from the live config. + public void Reload() + { + if (_scope == null) return; + + _draft = ConfigCloning.DeepClone(_scope.Config); + _draftCallsigns = AgentCallsigns.Enabled; + ReloadControls(); + SettingsStatus.Text = ""; + } + + /// Populates every control from the draft. Split from so a + /// rejected keystroke can snap the controls back without discarding the rest of the draft. + private void ReloadControls() + { + if (_draft is not { } cfg) return; + + _loading = true; + try + { + EndpointBox.Text = cfg.OllamaEndpoint; + _modelComboTarget = cfg.GetEffectiveModelName(); + ApplyModelComboTarget(); + S_ContextLength.Value = cfg.ContextLength; + S_Temperature.Value = cfg.Temperature; + S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##"); + S_MaxTokens.Value = cfg.MaxTokens; + S_Streaming.SelectedItem = cfg.ResponseStreaming; + S_AgentCallsigns.IsOn = _draftCallsigns; + S_TaskPlanning.IsOn = cfg.EnableTaskPlanning; + S_DiffApprovals.IsOn = cfg.EnableDiffApprovals; + S_AutoContinue.IsOn = cfg.EnableAutoContinuation; + S_MaxContinuations.Value = cfg.MaxAutoContinuations; + S_RequestTimeout.Value = cfg.RequestTimeoutMinutes; + S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds; + S_ToolBudget.Value = cfg.ToolResultCharBudget; + S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds; + S_WebSearch.IsOn = cfg.EnableWebSearch; + S_TavilyKey.Password = cfg.TavilyApiKey ?? ""; + S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden; + TavilyViewButton.Content = "View"; + TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey); + + RefreshScopeText(); + } + finally + { + _loading = false; + } + + RefreshDirtyState(); + } + + private void RefreshScopeText() + { + if (_scope != null) ScopeText.Text = _scope.ScopeDescription; + } + + // ============================================================ + // Staging + // ============================================================ + + /// + /// The single write path: validate against the DRAFT, never the live config. A rejected value + /// snaps the controls back and says why; an accepted one just moves the unsaved-changes count. + /// + private void Stage(string key, string value) + { + if (_draft == null) return; + + var result = ConfigKeySetter.TrySet(_draft, key, value); + if (!result.Ok) + { + SettingsStatus.Text = result.Message; + ReloadControls(); // put the control back to the last good value + return; + } + + SettingsStatus.Text = ""; + RefreshDirtyState(); + } + + /// Drives the Save button and the pending count off a real diff, so undoing an edit by + /// hand correctly returns the form to "nothing to save". + private void RefreshDirtyState() + { + if (_scope == null || _draft == null) return; + + // Keys owned by another surface are never pending changes here, and the scopes re-source + // them on commit — so a long-open form cannot write a stale value back over a change made + // elsewhere while it sat there. + var changed = ConfigCloning + .DifferingKeys(_draft, _scope.Config, _scope.KeysOwnedElsewhere.ToArray()) + .Count; + if (_draftCallsigns != AgentCallsigns.Enabled) changed++; + + SaveButton.IsEnabled = changed > 0; + // Appended to the button's own label rather than standing alone, so the count reads + // as part of the save it describes instead of as a caption under it. + UnsavedText.Text = changed == 0 + ? "" + : $"\u00b7 {changed} unsaved change{(changed == 1 ? "" : "s")}"; + } + + private void Setting_Toggled(object sender, RoutedEventArgs e) + { + if (_loading) return; + var toggle = (ToggleSwitch)sender; + Stage((string)toggle.Tag, toggle.IsOn ? "true" : "false"); + } + + private void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + if (_loading) return; + + // Clearing the box (its "X") or typing something invalid yields NaN. Don't stage it, and + // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons + // keep working. If even the old value is gone, repopulate from the draft. + if (double.IsNaN(args.NewValue)) + { + if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue; + else ReloadControls(); + return; + } + + Stage((string)sender.Tag, ((long)args.NewValue).ToString()); + } + + private void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) + { + if (_loading) return; + S_TemperatureLabel.Text = e.NewValue.ToString("0.##"); + Stage("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)); + } + + private void Streaming_Changed(object sender, SelectionChangedEventArgs e) + { + if (_loading || S_Streaming.SelectedItem is not string mode) return; + Stage("streaming", mode); + } + + /// Staged straight onto the draft rather than through ConfigKeySetter: that heals and + /// validates URLs, which fights a half-typed one. The endpoint is probed for real on save. + private void Endpoint_Changed(object sender, TextChangedEventArgs e) + { + if (_loading || _draft == null) return; + _draft.OllamaEndpoint = EndpointBox.Text.Trim(); + RefreshDirtyState(); + } + + private void ModelCombo_Changed(object sender, object e) + { + if (_loading || _draft == null) return; + var picked = (ModelCombo.SelectedItem as string) ?? ModelCombo.Text; + if (string.IsNullOrWhiteSpace(picked)) return; + _draft.ModelName = picked.Trim(); + _draft.ModelPath = null; + RefreshDirtyState(); + } + + /// App-wide naming style for new agents. Not a config key, so it's staged on its own + /// field and committed alongside the draft. + private void AgentCallsigns_Toggled(object sender, RoutedEventArgs e) + { + if (_loading) return; + _draftCallsigns = S_AgentCallsigns.IsOn; + RefreshDirtyState(); + } + + private void RunSetupWizard_Click(object sender, RoutedEventArgs e) => SetupWizardRequested?.Invoke(); + + /// The wizard runs in a chat transcript, so it needs an agent to run in. The host keeps + /// this in step with whether one is open. + public bool SetupWizardEnabled + { + get => SetupWizardButton.IsEnabled; + set + { + SetupWizardButton.IsEnabled = value; + SetupWizardHint.Text = value + ? "walks through connection + model, then saves them as the defaults" + : "open an agent first — the wizard runs in a chat"; + } + } + + // ============================================================ + // Saving + // ============================================================ + + private async void Save_Click(object sender, RoutedEventArgs e) + { + if (_scope == null || _draft == null) return; + + var keyChanged = !string.Equals(_draft.TavilyApiKey, _scope.Config.TavilyApiKey, StringComparison.Ordinal); + + SaveButton.IsEnabled = false; + SettingsStatus.Text = "Saving…"; + string message; + try + { + message = await _scope.CommitAsync(_draft); + } + catch (Exception ex) + { + message = $"Couldn't save: {ex.Message}"; + } + + AgentCallsigns.Enabled = _draftCallsigns; + if (keyChanged) SecretsChanged?.Invoke(); + + // Re-clone from the now-updated live config: the draft is spent, and the commit may have + // clamped a value or healed the endpoint, which the form should show. + Reload(); + SettingsStatus.Text = message; + + SettingsChanged?.Invoke(); + } + + /// + /// "Apply Global Defaults" — replaces this agent's settings with the saved defaults and puts it + /// back to INHERITING them. Deliberately immediate rather than staged: it isn't an edit to one + /// field, it discards the agent's whole config (any draft included) and re-points it. + /// + private async void ApplyDefaults_Click(object sender, RoutedEventArgs e) + { + if (_scope is not AgentSettingsScope agentScope) return; + + var agent = agentScope.Session; + ApplyDefaultsButton.IsEnabled = false; + try + { + agent.ResetConfigToDefaults(agentScope.Configs); + // The config changed underneath the live AI session — rebuild it so the new + // endpoint/model/context take effect. History survives; see RefreshFromConfigAsync. + await agent.Controller.RefreshFromConfigAsync(); + } + finally + { + ApplyDefaultsButton.IsEnabled = true; + } + + Reload(); + SettingsStatus.Text = $"{agent.Title} now matches the defaults for new agents, and will follow " + + "future changes to them until you save something here."; + SettingsChanged?.Invoke(); + } + + /// + /// "Save to Global Defaults" — makes this agent's settings the starting point for new agents. + /// + /// Works whatever state the form is in. Anything on screen but unsaved is committed to the + /// agent FIRST, so the defaults can never end up holding values the agent they came from isn't + /// actually running — the two always agree afterwards, and nothing is left pending. + /// + /// A side effect worth knowing: promoting leaves the agent identical to the defaults, so under + /// the inheriting rule it drops its own saved file and follows later changes to them again. + /// That is the honest outcome — it has no settings of its own left to keep — and the status + /// line says so. + /// + private async void SaveToDefaults_Click(object sender, RoutedEventArgs e) + { + if (_scope is not AgentSettingsScope agentScope || _draft == null) return; + + var session = agentScope.Session; + var hadPendingEdits = HasUnsavedChanges; + + SaveToDefaultsButton.IsEnabled = false; + SettingsStatus.Text = "Saving…"; + string message; + try + { + if (hadPendingEdits) + { + await _scope.CommitAsync(_draft); + AgentCallsigns.Enabled = _draftCallsigns; + } + + session.Controller.SaveAsDefaults(); + // The agent now matches the defaults exactly, so this drops its own file: it is back to + // inheriting rather than holding a private copy of what it just published. + session.PersistConfigIfChanged(); + + message = hadPendingEdits + ? $"Saved to {session.Title} and copied to the defaults for new agents. " + + "Agents already open keep their own." + : $"Copied {session.Title}'s settings to the defaults for new agents. " + + "Agents already open keep their own."; + } + catch (Exception ex) + { + message = $"Couldn't save to the defaults: {ex.Message}"; + } + finally + { + SaveToDefaultsButton.IsEnabled = true; + } + + Reload(); + SettingsStatus.Text = message; + SettingsChanged?.Invoke(); + } + + /// + /// Puts the visible tab back to the app's FACTORY values — the property initializers on + /// MandoCodeConfig. Defaults scope only: an agent has the saved defaults to fall back on + /// instead, which is what "Apply Global Defaults" is for. Staged like any other edit, so it + /// needs a Save. Connection and the Tavily secret are left alone — not tunable knobs. + /// + private void ResetFactory_Click(object sender, RoutedEventArgs e) + { + if (_draft == null) return; + + var d = new MandoCodeConfig(); // factory defaults (property initializers) + var s = SettingsTabs.SelectedItem; + + static string Bool(bool b) => b ? "true" : "false"; + static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture); + + var resets = new List<(string Key, string Value)>(); + string tabName; + + if (s == Tab_Behavior) + { + tabName = "Behavior"; + resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning))); + resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals))); + resets.Add(("autoContinue", Bool(d.EnableAutoContinuation))); + resets.Add(("maxContinuations", Num(d.MaxAutoContinuations))); + resets.Add(("timeout", Num(d.RequestTimeoutMinutes))); + resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds))); + resets.Add(("toolBudget", Num(d.ToolResultCharBudget))); + resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds))); + } + else if (s == Tab_Integrations) + { + tabName = "Integrations"; + resets.Add(("webSearch", Bool(d.EnableWebSearch))); + } + else + { + tabName = "Model"; + resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture))); + resets.Add(("maxTokens", Num(d.MaxTokens))); + resets.Add(("contextLength", Num(d.ContextLength))); + resets.Add(("streaming", d.ResponseStreaming)); + } + + foreach (var (key, value) in resets) ConfigKeySetter.TrySet(_draft, key, value); + + ReloadControls(); + SettingsStatus.Text = $"{tabName} set to factory values — press Save to keep it."; + } + + // ============================================================ + // Model picker + // ============================================================ + + private async void RefreshModels_Click(object sender, RoutedEventArgs e) => await RefreshModelListAsync(); + + /// Fills the model picker without making the form wait on the network: the configured + /// model is already known, so it shows selected on the first frame, then the installed-model + /// list (a probe plus an Ollama /api/tags fetch, slow on cloud setups) fills in behind it. + private async Task RefreshModelListAsync() + { + if (_scope == null) return; + // No picker on this scope, so nothing to fill — and no reason to probe Ollama every time + // the pane opens. + if (ConnectionSection.Visibility != Visibility.Visible) return; + + // Instant: seed with the one model we already know, so the picker never sits empty. Only on + // a first open — a manual refresh keeps the list it has until the new one arrives. + var configured = (_draft ?? _scope.Config).GetEffectiveModelName(); + if (!string.IsNullOrEmpty(configured) && + (ModelCombo.ItemsSource is not IList present || present.Count == 0)) + { + _modelComboTarget = configured; + SetModelItems(new List { configured }); + } + + ModelListStatus.Text = "Fetching models…"; + var models = await _scope.ListModelsAsync(); + if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; + + // A failed or empty fetch keeps whatever is already selectable. Replacing it with an empty + // list would blank a picker that was showing the right answer a moment ago. + if (models.Count == 0) + { + ModelListStatus.Text = "No models found — is Ollama running? (ollama serve, then ollama pull )"; + return; + } + + // A configured model the fetch doesn't list (a cloud model with nothing pulled locally) + // still belongs in the picker — it is what the agent is actually using. + if (!string.IsNullOrEmpty(_modelComboTarget) && + !models.Any(m => string.Equals(m, _modelComboTarget, StringComparison.OrdinalIgnoreCase))) + models.Insert(0, _modelComboTarget); + + SetModelItems(ModelOrdering.Arrange(models)); + ModelListStatus.Text = $"{models.Count} model(s) available."; + } + + /// Replaces the picker's items under the loading guard. Without it, re-projecting the + /// list fires SelectionChanged and stages a "change" the user never made — lighting up Save on + /// a page they only looked at. + private void SetModelItems(IList items) + { + _loading = true; + try + { + ModelCombo.ItemsSource = items; + ApplyModelComboTarget(); + } + finally + { + _loading = false; + } + } + + /// Selects the target model in the picker, falling back to the editable text when the + /// list doesn't contain it. Selection is set by INDEX rather than by SelectedItem — an editable + /// ComboBox drops a programmatic SelectedItem while its template isn't loaded. + private void ApplyModelComboTarget() + { + if (string.IsNullOrEmpty(_modelComboTarget)) return; + + if (ModelCombo.ItemsSource is IList items) + { + var idx = -1; + for (int i = 0; i < items.Count; i++) + if (string.Equals(items[i], _modelComboTarget, StringComparison.OrdinalIgnoreCase)) { idx = i; break; } + if (idx >= 0) + { + ModelCombo.SelectedIndex = idx; + return; + } + } + ModelCombo.Text = _modelComboTarget; + } + + /// + /// Pin toggle inside a dropdown row. Handled on Tapped rather than Click so the tap stops here + /// instead of bubbling to the ComboBoxItem, which would otherwise treat pinning as picking the + /// model and close the dropdown on the way out. Pinning is display order, not a setting — it + /// applies immediately and is never part of the draft. + /// + private void ModelPin_Tapped(object sender, TappedRoutedEventArgs e) + { + e.Handled = true; + if (sender is not FrameworkElement { Tag: string model } || string.IsNullOrWhiteSpace(model)) return; + ModelOrdering.TogglePin(model); + + if (ModelCombo.ItemsSource is not IList current) return; + if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; + SetModelItems(ModelOrdering.Arrange(current)); + } + + // ============================================================ + // Tavily key (app-wide — defaults scope only) + // ============================================================ + + /// Staged like any other setting; the defaults scope's commit writes it to the shared + /// config and fans it out to every live agent. + private void TavilyKey_Changed(object sender, RoutedEventArgs e) + { + TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0; + if (_loading || _draft == null) return; + + var typed = S_TavilyKey.Password.Trim(); + _draft.TavilyApiKey = typed.Length == 0 ? null : typed; + RefreshDirtyState(); + } + + private void TavilyView_Click(object sender, RoutedEventArgs e) + { + var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible; + S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden; + TavilyViewButton.Content = show ? "Hide" : "View"; + } + + // ============================================================ + // Tabs + // ============================================================ + + private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) + { + var s = sender.SelectedItem; + TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed; + TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed; + TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed; + + // Reset acts on the visible tab, so its label names that tab. + ResetFactoryText.Text = s == Tab_Behavior ? "Reset Behavior to Factory" + : s == Tab_Integrations ? "Reset Integrations to Factory" : "Reset Model to Factory"; + } +} diff --git a/src/MandoCode.Desktop/MainWindow.Appearance.cs b/src/MandoCode.Desktop/MainWindow.Appearance.cs index c5013d3..7b9f1ea 100644 --- a/src/MandoCode.Desktop/MainWindow.Appearance.cs +++ b/src/MandoCode.Desktop/MainWindow.Appearance.cs @@ -137,174 +137,14 @@ private void ApplyWindowOpacity(double opacity) } } + /// Applies a theme picked from the Appearance page. Guarded on _appearanceReady like + /// the opacity sliders: the constructor selects the live theme in this list, and that selection + /// must not be mistaken for the user choosing one. No status line — the whole window repaints, + /// and the header value beside the list already names the new theme. private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (_loadingSettings || ThemeList.SelectedItem is not ThemeVm vm) return; + if (!_appearanceReady || ThemeList.SelectedItem is not ThemeVm vm) return; ThemeManager.Apply(vm.Theme, Root); ThemeHeaderValue.Text = vm.Theme.Name; - SettingsStatus.Text = $"Theme set to {vm.Theme.Name}."; } - - private string _modelComboTarget = ""; - - /// An editable ComboBox drops programmatic Text while its template isn't - /// loaded (the Settings page starts collapsed) — so the intended model name is kept - /// here and re-applied on the combo's Loaded event. Selecting the matching pulled - /// model when one exists also marks it in the dropdown. - private void ApplyModelComboTarget() - { - if (_modelComboTarget.Length == 0) return; - if (ModelCombo.ItemsSource is IList models) - { - var idx = models.IndexOf(_modelComboTarget); - if (idx >= 0) - { - ModelCombo.SelectedIndex = idx; - return; - } - } - ModelCombo.Text = _modelComboTarget; - } - - /// One write path for the whole page: ConfigKeySetter via the controller. - private async Task ApplySettingAsync(string key, string value) - { - var (ok, message) = await _controller.ApplyConfigKeyAsync(key, value); - SettingsStatus.Text = message; - if (!ok) LoadSettings(); // revert the control to the real value - } - - private async void Setting_Toggled(object sender, RoutedEventArgs e) - { - if (_loadingSettings) return; - var toggle = (ToggleSwitch)sender; - await ApplySettingAsync((string)toggle.Tag, toggle.IsOn ? "true" : "false"); - } - - private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) - { - if (_loadingSettings) return; - - // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and - // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons - // keep working. If even the old value is gone, reload the whole form from config. - if (double.IsNaN(args.NewValue)) - { - if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue; - else LoadSettings(); - return; - } - - await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString()); - } - - private async void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) - { - if (_loadingSettings) return; - S_TemperatureLabel.Text = e.NewValue.ToString("0.##"); - await ApplySettingAsync("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)); - } - - private async void Streaming_Changed(object sender, SelectionChangedEventArgs e) - { - if (_loadingSettings || S_Streaming.SelectedItem is not string mode) return; - await ApplySettingAsync("streaming", mode); - } - - /// Enables View as soon as there's anything to reveal (saved key or fresh typing). - private void TavilyKey_Changed(object sender, RoutedEventArgs e) => - TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0; - - private void TavilyView_Click(object sender, RoutedEventArgs e) - { - var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible; - S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden; - TavilyViewButton.Content = show ? "Hide" : "View"; - } - - private async void TavilySave_Click(object sender, RoutedEventArgs e) - { - var key = S_TavilyKey.Password; - if (string.IsNullOrWhiteSpace(key)) - { - SettingsStatus.Text = "Enter a key first (or type 'clear' to remove the saved one)."; - return; - } - await ApplySettingAsync("tavilyKey", key.Trim()); - LoadSettings(); - } - - private async void RefreshModels_Click(object sender, RoutedEventArgs e) => - await RefreshModelListAsync(); - - /// - /// Pin toggle inside a dropdown row. Handled on Tapped rather than Click so the tap stops here - /// instead of bubbling to the ComboBoxItem, which would otherwise treat pinning as picking the - /// model and close the dropdown on the way out. - /// - private void ModelPin_Tapped(object sender, TappedRoutedEventArgs e) - { - e.Handled = true; - if (sender is not FrameworkElement { Tag: string model } || string.IsNullOrWhiteSpace(model)) return; - ModelOrdering.TogglePin(model); - - // Re-project the same names so the row's glyph re-renders and the pinned model moves up. - if (ModelCombo.ItemsSource is not IList current) return; - if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; - ModelCombo.ItemsSource = ModelOrdering.Arrange(current); - ApplyModelComboTarget(); - } - - /// Fills the model picker without making the page wait on the network: the configured - /// model is already known, so it is shown selected on the first frame, then the installed-model - /// list (a probe plus an Ollama /api/tags fetch, slow on cloud setups) fills in behind it for - /// "pick another". Same approach as LoadSnapshotModelsAsync. - private async Task RefreshModelListAsync() - { - // Instant: seed with the one model we already know, so the picker never sits empty. Only on - // a first open — a manual refresh keeps the list it has until the new one arrives. - var configured = _controller.Config.GetEffectiveModelName(); - if (!string.IsNullOrEmpty(configured) && - (ModelCombo.ItemsSource is not IList present || present.Count == 0)) - { - _modelComboTarget = configured; - ModelCombo.ItemsSource = new List { configured }; - ApplyModelComboTarget(); - } - - ModelListStatus.Text = "Fetching models…"; - var models = await Task.Run(_controller.ListModelsAsync); - if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; - - // A failed or empty fetch keeps whatever is already selectable. Replacing it with an empty - // list would blank a picker that was showing the right answer a moment ago. - if (models.Count == 0) - { - ModelListStatus.Text = "No models found — is Ollama running? (ollama serve, then ollama pull )"; - return; - } - - // A configured model the fetch doesn't list (a cloud model with nothing pulled locally) - // still belongs in the picker — it is what the agent is actually using. - if (!string.IsNullOrEmpty(_modelComboTarget) && - !models.Any(m => string.Equals(m, _modelComboTarget, StringComparison.OrdinalIgnoreCase))) - models.Insert(0, _modelComboTarget); - - ModelCombo.ItemsSource = ModelOrdering.Arrange(models); - ApplyModelComboTarget(); - ModelListStatus.Text = $"{models.Count} model(s) available."; - } - - private async void SettingsSave_Click(object sender, RoutedEventArgs e) - { - var endpoint = EndpointBox.Text; - var model = ModelCombo.Text; - SettingsStatus.Text = "Connecting… (details land in the chat transcript)"; - await Task.Run(() => _controller.ApplyConnectionSettingsAsync(endpoint, model)); - SettingsStatus.Text = _controller.IsConnected - ? $"✓ Connected — {_controller.ModelName}" - : "Couldn't connect — see the chat transcript for details."; - LoadSettings(); - } - } diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs index a472488..416eeaf 100644 --- a/src/MandoCode.Desktop/MainWindow.History.cs +++ b/src/MandoCode.Desktop/MainWindow.History.cs @@ -38,6 +38,7 @@ private void ArchiveClosedSession(AgentSession session) TranscriptJournal.Delete(key); ConversationLog.Delete(key); SessionHistoryStore.Delete(key); + AgentConfigStore.Delete(key); return; } @@ -358,66 +359,6 @@ private async void HistoryDeleteGroup_Click(object sender, RoutedEventArgs e) PopulateHistory(); } - /// "Make Default for New Agents" — snapshot the selected agent's settings to disk. - private void MakeDefault_Click(object sender, RoutedEventArgs e) - { - var agent = _sessions.Active; - if (agent == null) return; - - _controller.SaveAsDefaults(); - SettingsStatus.Text = $"Saved {agent.Title}'s settings as the default for new agents. " - + "Agents already open keep their own."; - } - - /// Resets the visible tab's settings to the app's factory defaults (this agent, this - /// session). Reads a fresh for the defaults and applies each key - /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the - /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset. - private async void ResetTab_Click(object sender, RoutedEventArgs e) - { - var d = new MandoCodeConfig(); // factory defaults (property initializers) - var s = SettingsTabs.SelectedItem; - var resets = new List<(string Key, string Value)>(); - string tabName; - - static string Bool(bool b) => b ? "true" : "false"; - static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture); - - if (s == Tab_Behavior) - { - tabName = "Behavior"; - resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning))); - resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals))); - resets.Add(("autoContinue", Bool(d.EnableAutoContinuation))); - resets.Add(("maxContinuations", Num(d.MaxAutoContinuations))); - resets.Add(("timeout", Num(d.RequestTimeoutMinutes))); - resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds))); - resets.Add(("toolBudget", Num(d.ToolResultCharBudget))); - resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds))); - } - else if (s == Tab_Integrations) - { - tabName = "Integrations"; - resets.Add(("webSearch", Bool(d.EnableWebSearch))); - } - else - { - tabName = "Model"; - resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture))); - resets.Add(("maxTokens", Num(d.MaxTokens))); - resets.Add(("contextLength", Num(d.ContextLength))); - resets.Add(("streaming", d.ResponseStreaming)); - } - - ResetTabButton.IsEnabled = false; - foreach (var (key, value) in resets) - await _controller.ApplyConfigKeyAsync(key, value); - ResetTabButton.IsEnabled = true; - - LoadSettings(); // reflect the restored values (also clears the status line) - SettingsStatus.Text = $"{tabName} settings reset to factory defaults."; - } - private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title) { var label = new TextBlock diff --git a/src/MandoCode.Desktop/MainWindow.Mcp.cs b/src/MandoCode.Desktop/MainWindow.Mcp.cs index 968fcfd..1e87fd4 100644 --- a/src/MandoCode.Desktop/MainWindow.Mcp.cs +++ b/src/MandoCode.Desktop/MainWindow.Mcp.cs @@ -38,7 +38,7 @@ private async Task RefreshMcpListAsync() // at the agent level. Make sure the active agent actually attaches tools (new agents inherit // EnableMcp=true from defaults; this only fires for an agent someone turned off previously). if (!_controller.Config.EnableMcp) - await ApplySettingAsync("mcp", "true"); + await _controller.ApplyConfigKeyAsync("mcp", "true"); McpPageStatus.Text = "Checking server status…"; var rows = await Task.Run(_controller.GetMcpStatusRowsAsync); diff --git a/src/MandoCode.Desktop/MainWindow.Navigation.cs b/src/MandoCode.Desktop/MainWindow.Navigation.cs index a7ed2bc..0540c0e 100644 --- a/src/MandoCode.Desktop/MainWindow.Navigation.cs +++ b/src/MandoCode.Desktop/MainWindow.Navigation.cs @@ -80,8 +80,7 @@ private void SwitchPage(string page) switch (page) { case "settings": - LoadSettings(); - _ = RefreshModelListAsync(); + LoadSettings(); // SettingsForm.Reload — it fetches its own model list break; case "mcp": _ = RefreshMcpListAsync(); @@ -148,10 +147,13 @@ private void RefreshNavIcons() NavNotesIcon.Foreground = NotesPanelOpen ? accent : normal; NavTerminalIcon.Foreground = _terminalOpen ? accent : normal; - // Settings and MCP act on the selected agent — disable them while none is open. - var hasAgent = _sessions.Active != null; - NavSettings.IsEnabled = hasAgent; - NavMcp.IsEnabled = hasAgent; + // MCP acts on the selected agent, so it needs one open. Settings no longer does — it edits + // the defaults for FUTURE agents, which is exactly the thing you might want to set up before + // opening any. (An agent's own settings are behind its gear icon instead.) + NavMcp.IsEnabled = _sessions.Active != null; + // The guided wizard renders into a chat transcript, so it needs one open even though what + // it configures is app-wide. + DefaultsSettingsForm.SetupWizardEnabled = _sessions.Active != null; ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents"); } diff --git a/src/MandoCode.Desktop/MainWindow.Settings.cs b/src/MandoCode.Desktop/MainWindow.Settings.cs index 7ad8c17..9c451fe 100644 --- a/src/MandoCode.Desktop/MainWindow.Settings.cs +++ b/src/MandoCode.Desktop/MainWindow.Settings.cs @@ -25,81 +25,13 @@ public sealed partial class MainWindow // Settings page // ============================================================ - private bool _loadingSettings; - - /// Populates every control from the live config. Guarded so control-change - /// events fired during population don't write back. + /// Points the rail's Settings page at the global defaults. Called on page open, so + /// the form always reflects a default another surface may have changed (a healed endpoint, an + /// agent promoted with "Make Default for New Agents"). private void LoadSettings() { - _loadingSettings = true; - try - { - // The SELECTED agent's config, not the saved defaults. Switch agents and this page - // shows different values. - var cfg = _controller.Config; - SettingsAgentChip.Text = _sessions.Active?.Title ?? ""; - EndpointBox.Text = cfg.OllamaEndpoint; - _modelComboTarget = cfg.GetEffectiveModelName(); - ApplyModelComboTarget(); - S_ContextLength.Value = cfg.ContextLength; - S_Temperature.Value = cfg.Temperature; - S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##"); - S_MaxTokens.Value = cfg.MaxTokens; - S_Streaming.SelectedItem = cfg.ResponseStreaming; - S_AgentCallsigns.IsOn = AgentCallsigns.Enabled; // app-wide, not from the agent's config - S_TaskPlanning.IsOn = cfg.EnableTaskPlanning; - S_DiffApprovals.IsOn = cfg.EnableDiffApprovals; - S_AutoContinue.IsOn = cfg.EnableAutoContinuation; - S_MaxContinuations.Value = cfg.MaxAutoContinuations; - S_RequestTimeout.Value = cfg.RequestTimeoutMinutes; - S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds; - S_ToolBudget.Value = cfg.ToolResultCharBudget; - S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds; - S_WebSearch.IsOn = cfg.EnableWebSearch; - S_TavilyKey.Password = cfg.TavilyApiKey ?? ""; - S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden; - TavilyViewButton.Content = "View"; - TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey); - for (int i = 0; i < UiTheme.All.Count; i++) - if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i; - SettingsStatus.Text = ""; - } - finally - { - _loadingSettings = false; - } - } - - /// App-wide naming style for new agents — applies immediately (like Appearance), - /// not through the agent config / Make Default flow the rest of the page uses. - private void AgentCallsigns_Toggled(object sender, RoutedEventArgs e) - { - if (_loadingSettings) return; - AgentCallsigns.Enabled = S_AgentCallsigns.IsOn; - SavePanelState(); - } - - /// Runs the guided /setup wizard in the active agent's chat — the same flow that - /// fires on first launch. Routed through SubmitAsync so it gets the standard command echo - /// and the is-processing guard. - private void RunSetupWizard_Click(object sender, RoutedEventArgs e) - { - SwitchPage("chat"); - _ = Task.Run(() => _controller.SubmitAsync("/setup")); - } - - private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) - { - var s = sender.SelectedItem; - TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed; - TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed; - TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed; - - // "Reset" acts on the visible tab, so its label names that tab. - ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior" - : s == Tab_Integrations ? "Reset Integrations" : "Reset Model"; - // Every remaining tab is per-agent now (Appearance moved to its own rail page), so - // "Make Default for New Agents" always applies. + DefaultsSettingsForm.Reload(); + _ = DefaultsSettingsForm.RefreshModelsAsync(); } /// False until the constructor has loaded persisted appearance settings into the diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs index 104ecc2..38309f8 100644 --- a/src/MandoCode.Desktop/MainWindow.Tabs.cs +++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs @@ -61,6 +61,10 @@ private sealed class ChatTabEntry private void AddTab_Click(object sender, RoutedEventArgs e) { var entry = CreateChatTab(); + // No restore cascade on a brand-new agent — nothing is going to move its config out from + // under it, so its settings can persist from the first change. (Restored tabs are armed by + // InitTabAsync instead, once their saved model has landed.) + entry.View.Session.ConfigPersistenceArmed = true; _ = entry.View.InitializeAsync(); SaveWorkspace(); } @@ -71,7 +75,9 @@ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = n if (!string.IsNullOrWhiteSpace(title)) session.Title = title; var view = new ChatTabView(this, session, _html) { Visibility = Visibility.Collapsed }; - view.SetupRequested += () => SwitchPage("settings"); + // The agent opens its OWN settings pane (ChatTabView.OnSetupNeeded); all this has to do is + // make sure the chat is the visible page, so the pane isn't opening behind a rail page. + view.SetupRequested += () => SwitchPage("chat"); view.McpEditorRequested += name => { SwitchPage("mcp"); diff --git a/src/MandoCode.Desktop/MainWindow.Terminal.cs b/src/MandoCode.Desktop/MainWindow.Terminal.cs index 23a1270..48812aa 100644 --- a/src/MandoCode.Desktop/MainWindow.Terminal.cs +++ b/src/MandoCode.Desktop/MainWindow.Terminal.cs @@ -231,6 +231,12 @@ private async Task InitTabAsync(ChatTabEntry entry) } finally { + // Settings persistence opens here for the same reason workspace writes do: until the + // restore cascade has run, this tab is still on the seeded default, and a write in that + // window would save the default over the user's own settings. A throw above still arms + // it — the tab is done moving either way, and it must not be stuck read-only for the + // rest of the session. + entry.View.Session.ConfigPersistenceArmed = true; // Last one out writes the workspace, now that every tab reports its real model. if (Interlocked.Decrement(ref _restoringTabs) == 0) SaveWorkspace(); } @@ -299,6 +305,11 @@ private void SaveWorkspace() tabs, active, panes, panes == null ? null : new List(_colFractions), panes == null ? null : new List(_rowFractions))); + + // Safety net for per-agent settings. ChatController.ConfigChanged catches the deliberate + // edits; this catches anything that mutates a config without raising it (a healed endpoint, + // a wizard). Each call is a fingerprint compare and writes nothing when nothing moved. + foreach (var tab in _tabs) tab.View.Session.PersistConfigIfChanged(); } } diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index d38b8eb..a3e4bbf 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -16,8 +16,10 @@ @@ -73,7 +75,7 @@ @@ -943,240 +945,38 @@ - + - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 2e466e5..a28b784 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -67,10 +67,13 @@ 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_Model; // the setup that matters most opens first ThemeList.ItemsSource = UiTheme.All.Select(t => new ThemeVm { Theme = t }).ToList(); ThemeHeaderValue.Text = ThemeManager.Current.Name; - ModelCombo.Loaded += (_, _) => ApplyModelComboTarget(); + // Selecting the live theme here rather than from the Settings page's load, where it used to + // sit: the theme list belongs to Appearance, and Settings no longer knows anything about it. + // Before _appearanceReady, so this selection can't be mistaken for the user picking one. + for (int i = 0; i < UiTheme.All.Count; i++) + if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i; S_WindowOpacity.Value = ThemeManager.WindowOpacity * 100; S_WindowOpacityLabel.Text = $"{(int)S_WindowOpacity.Value}%"; ApplyWindowOpacity(ThemeManager.WindowOpacity); @@ -89,6 +92,20 @@ public MainWindow() _archive = services.GetRequiredService(); _skillCoordinator = services.GetRequiredService(); _configs = services.GetRequiredService(); + + // The rail page edits the global defaults. Bound once, for the life of the window: unlike an + // agent's pane there is nothing to re-point it at. + DefaultsSettingsForm.Bind(new DefaultsSettingsScope(_configs)); + // The Tavily key is app-wide and lives only here — mirror a change into every agent already + // open, the same way an MCP server edit is mirrored. + DefaultsSettingsForm.SecretsChanged += () => _configs.SyncSecretsToAgents(); + // The wizard configures the APP but has to run somewhere visible, so it runs in the open + // agent's chat and ends by writing the defaults this page shows. + DefaultsSettingsForm.SetupWizardRequested += () => + { + SwitchPage("chat"); + _ = Task.Run(() => _controller.SubmitAsync("/setup")); + }; _music = services.GetRequiredService(); // Changed can fire on a background thread (a capture during a model switch). _snapshotStore.Changed += () => OnUi(OnSnapshotsChanged); @@ -144,6 +161,7 @@ public MainWindow() TranscriptJournal.Sweep(liveKeys); ConversationLog.Sweep(liveKeys); SessionHistoryStore.Sweep(liveKeys); + AgentConfigStore.Sweep(liveKeys); // Size the window; defer WebView2 + harness init until the tree is loaded. AppWindow.Resize(new Windows.Graphics.SizeInt32(1180, 840)); diff --git a/src/MandoCode.Desktop/Services/AgentConfigStore.cs b/src/MandoCode.Desktop/Services/AgentConfigStore.cs new file mode 100644 index 0000000..dd36282 --- /dev/null +++ b/src/MandoCode.Desktop/Services/AgentConfigStore.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using MandoCode.Models; + +namespace MandoCode.Desktop.Services; + +/// +/// Per-agent settings that survive the process: one JSON file per agent, named by its durable +/// , so a relaunch or a reopen from History brings an agent +/// back on its OWN settings rather than on whatever the global defaults happen to be now. +/// +/// An agent only gets a file once its settings are actually CHANGED. Until then it has no file +/// and is seeded fresh from every time it loads — so a +/// global default you raise today still reaches every agent you never configured. The first +/// change snapshots the whole config and the agent is independent from then on; "Match global +/// defaults" deletes the file and puts it back to inheriting. +/// +/// SECRETS ARE NEVER WRITTEN HERE. is stripped from the JSON on the +/// way out, so an API key lives in exactly one file on disk (the shared ~/.mandocode/config.json) +/// no matter how many agents get configured. puts +/// it back on the in-memory clone at load, so everything downstream still reads +/// Config.TavilyApiKey and sees a key. +/// +/// Best-effort on both ends, like and : an +/// unreadable or corrupt file just means that agent starts on the defaults, never a crash. +/// +public static class AgentConfigStore +{ + /// + /// Config keys (by JSON property name) that never belong in a per-agent file. Add to this + /// rather than special-casing a new one elsewhere. + /// • tavilyApiKey — a secret. One copy on disk, in the shared config, however many agents + /// get configured. + /// • mcpServers — app-wide by design (one shared McpClientManager owns the processes), and + /// CreateCloneFor re-sources it from Defaults on every load, so a persisted copy would be + /// dead weight. Excluding it ALSO keeps the fingerprint honest: editing the MCP page + /// replaces this dictionary on every live agent, and a fresh dictionary can serialize in a + /// different order — which would read as "the user changed this agent's settings" and + /// silently drop every open agent out of inheriting the defaults. + /// (EnableMcp is a separate top-level key and stays: whether an agent USES the shared servers + /// is genuinely per-agent.) + /// + private static readonly string[] ExcludedJsonKeys = ["tavilyApiKey", "mcpServers"]; + + private static readonly object Gate = new(); + + private static string Folder => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MandoCode.Desktop", "agent-configs"); + + private static string PathFor(string key) => Path.Combine(Folder, key + ".json"); + + /// True once this agent has been configured — i.e. it no longer tracks the defaults. + public static bool Exists(string key) + { + try { return File.Exists(PathFor(key)); } + catch { return false; } + } + + /// This agent's saved settings, or null if it has never been configured (or the file + /// is unreadable). The returned config has NO secrets — the caller injects them. + public static MandoCodeConfig? TryLoad(string key) + { + try + { + string json; + lock (Gate) + { + if (!File.Exists(PathFor(key))) return null; + json = File.ReadAllText(PathFor(key)); + } + return ConfigCloning.Deserialize(json); + } + catch + { + return null; + } + } + + /// Snapshots an agent's settings, minus secrets. Returns the exact text written, so + /// the caller can hold it as the "last persisted" fingerprint and skip no-op rewrites. + public static string? Save(string key, MandoCodeConfig config) + { + try + { + var json = Fingerprint(config); + lock (Gate) + { + Directory.CreateDirectory(Folder); + // Write-then-rename, same as SessionHistoryStore: a crash mid-write must not leave + // a torn file where a good snapshot of the user's settings used to be. + var tmp = PathFor(key) + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, PathFor(key), overwrite: true); + } + return json; + } + catch + { + return null; + } + } + + /// The exact bytes would write — secrets stripped. Comparing this + /// against the last-saved text is how an agent detects its FIRST real change without having to + /// intercept every one of the dozen places a config can be mutated. + public static string Fingerprint(MandoCodeConfig config) + { + var node = JsonNode.Parse(ConfigCloning.Serialize(config))!.AsObject(); + foreach (var excluded in ExcludedJsonKeys) node.Remove(excluded); + return node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + /// Puts an agent back to inheriting the global defaults. + public static void Delete(string key) + { + try { lock (Gate) File.Delete(PathFor(key)); } + catch { } + } + + /// Drops configs for agents that are neither open nor recoverable from History — + /// tabs lost to a crash, folders pruned. Same keep-set as the transcript/memory sweeps. + public static void Sweep(IEnumerable liveKeys) + { + try + { + if (!Directory.Exists(Folder)) return; + var keep = new HashSet(liveKeys, StringComparer.OrdinalIgnoreCase); + foreach (var file in Directory.EnumerateFiles(Folder, "*.json")) + if (!keep.Contains(Path.GetFileNameWithoutExtension(file))) + try { File.Delete(file); } catch { } + } + catch { } + } +} diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index cd7c1d2..6da1e8c 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -101,7 +101,12 @@ public AgentSession( var updateCheck = globals.GetRequiredService(); Snapshots = globals.GetRequiredService(); - Config = configs.CreateClone(); + // Boots on this agent's OWN saved settings if it has ever been configured, otherwise on a + // fresh clone of the defaults. Must be before AIService below, which bakes the system + // prompt (and reads the model) in its constructor. + Config = configs.CreateCloneFor(PersistKey); + _configs = configs; + _persistedConfigJson = AgentConfigStore.Fingerprint(Config); ProjectRoot = new ProjectRootAccessor(projectRoot); // Before AIService below: its constructor bakes the system prompt, and the agent's // spoken identity (Config.AgentName, stamped by the Title setter) must be in it. @@ -196,6 +201,87 @@ public AgentSession( // Tier-3 persistence: plain-text turns feed the ConversationLog so a restored // session can re-brief the model. Controller.ConversationLogger = (role, text) => ConversationLog.Append(PersistKey, role, text); + + // Settings persistence: every deliberate config change offers itself here. The fingerprint + // compare inside makes a no-op change free, so the controller can raise this liberally. + Controller.ConfigChanged += PersistConfigIfChanged; + } + + // ---- Per-agent settings persistence ---- + + /// The config as last written to disk (secrets stripped), or — for an agent that has + /// never been configured — as it looked at boot. Anything that differs from this is a real + /// change the user made, which is what turns an inheriting agent into an independent one. + private string _persistedConfigJson; + + /// Held for the defaults comparison in . + private readonly ConfigCoordinator _configs; + + /// + /// False until the tab's restore cascade has finished. Restore itself moves the config (a saved + /// model is applied AFTER construction), and persisting inside that window is the same trap + /// SaveWorkspace guards against: a tab still sitting on the default would stamp the default + /// over the user's real choice. MainWindow arms this once the tab has settled. + /// + public bool ConfigPersistenceArmed { get; set; } + + /// + /// Writes this agent's settings if they have actually moved. Cheap enough to call on any + /// checkpoint — it serializes ~4KB and compares, and does no I/O when nothing changed. + /// + public void PersistConfigIfChanged() + { + if (!ConfigPersistenceArmed) return; + + try + { + // Serializing a config that another thread is mid-mutation on can throw (a collection + // modified during enumeration). ConfigChanged reaches here off the UI thread — from + // ApplyConnectionSettingsAsync and model switches — so this must never be the thing + // that takes the app down. Settings are best-effort persistence, like every sibling + // store; the next change writes them. + var current = AgentConfigStore.Fingerprint(Config); + + // An agent whose settings are IDENTICAL to the defaults is an inheriting agent, however + // it got there — there is nothing to remember that re-reading the defaults wouldn't + // give back, and staying independent would only mean silently missing future changes. + // + // /setup is why this matters rather than being a nicety: the wizard ends with + // SaveDefaultsFrom (see ChatController.Wizards — "/setup configures the app, not one + // agent"), so the agent it ran in matches the defaults exactly. Without this, running + // the wizard would quietly drop that agent out of inheriting as a side effect. + if (current == AgentConfigStore.Fingerprint(_configs.Defaults)) + { + if (AgentConfigStore.Exists(PersistKey)) AgentConfigStore.Delete(PersistKey); + _persistedConfigJson = current; + return; + } + + if (current == _persistedConfigJson) return; + + // Hold the new fingerprint even if the write itself failed: a disk that can't take the + // file won't be fixed by retrying on every keystroke, and the in-memory settings are + // still correct either way. + _persistedConfigJson = current; + AgentConfigStore.Save(PersistKey, Config); + } + catch { } + } + + /// + /// "Match global defaults" — drops this agent's saved settings and puts it back on the current + /// defaults, inheriting future changes again. The live is mutated in place + /// so every collaborator holding a reference to it (AIService, SkillLoader, McpApprovalGate) + /// sees the new values; the caller still has to rebuild the agent for them to take effect — + /// see ChatController.RefreshFromConfigAsync. + /// + public void ResetConfigToDefaults(ConfigCoordinator configs) + { + AgentConfigStore.Delete(PersistKey); + configs.CopyDefaultsOnto(Config); + // Back to inheriting: the fingerprint is the defaults, so no file is written again until + // the user makes a fresh change. + _persistedConfigJson = AgentConfigStore.Fingerprint(Config); } /// Repoints this tab at a different project folder and rebuilds its AI session. diff --git a/src/MandoCode.Desktop/Services/ConfigCloning.cs b/src/MandoCode.Desktop/Services/ConfigCloning.cs index b7c1127..c8a63b7 100644 --- a/src/MandoCode.Desktop/Services/ConfigCloning.cs +++ b/src/MandoCode.Desktop/Services/ConfigCloning.cs @@ -1,4 +1,6 @@ +using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using MandoCode.Models; namespace MandoCode.Desktop.Services; @@ -24,10 +26,84 @@ public static class ConfigCloning WriteIndented = true }; + /// The write half of the round-trip, on its own. + /// persists through this rather than through its own serializer so a saved agent config and a + /// clone can never disagree about casing or indentation. + public static string Serialize(MandoCodeConfig config) => + JsonSerializer.Serialize(config, WriteOptions); + + /// The read half. Returns null on malformed JSON — callers here are all best-effort + /// restores that fall back to the defaults rather than failing. + public static MandoCodeConfig? Deserialize(string json) + { + var config = JsonSerializer.Deserialize(json, ReadOptions); + // Same reason as DeepClone below: System.Text.Json rebuilds McpServers with the default + // case-SENSITIVE comparer, and every lookup in it would then miss on a casing difference. + config?.ValidateAndClamp(); + return config; + } + + // Reflected once. Using reflection rather than a hand-written field list means a config key + // added by the CLI harness is carried across without a change here. + private static readonly PropertyInfo[] SettableProperties = typeof(MandoCodeConfig) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) + .ToArray(); + + /// + /// Overwrites 's settings with 's, IN PLACE — + /// the instance is shared with whoever captured it (an agent's AIService, SkillLoader and + /// McpApprovalGate all hold the same object), so replacing the reference would leave every one + /// of them on the old values. + /// + /// is never carried across. It is a tab's spoken + /// identity rather than a setting: copying it INTO an agent (from defaults that have none) + /// blanks that agent's name out of its own system prompt, and copying it OUT of one would stamp + /// a single agent's name onto the defaults every other agent is seeded from. + /// + public static void CopyOnto(MandoCodeConfig source, MandoCodeConfig target) + { + // Deep-clone first: reflection assigns reference-typed members straight across, and the two + // configs must not end up sharing List/Dictionary instances — one side's edit would be both. + var snapshot = DeepClone(source); + var targetAgentName = target.AgentName; + + foreach (var property in SettableProperties) + property.SetValue(target, property.GetValue(snapshot)); + + target.AgentName = targetAgentName; + target.ValidateAndClamp(); + } + + /// + /// The config keys (JSON property names) whose values differ between two configs. Backs the + /// settings form's unsaved-changes count: the form edits a DRAFT clone and commits it on Save, + /// so "what is pending" is exactly this diff against the live config. + /// + /// Compared as serialized JSON per property rather than by value, so lists and dictionaries + /// (ignoreDirectories, mcpServers) compare by content instead of by reference. + /// + public static IReadOnlyList DifferingKeys( + MandoCodeConfig a, MandoCodeConfig b, params string[] ignore) + { + var left = JsonNode.Parse(Serialize(a))!.AsObject(); + var right = JsonNode.Parse(Serialize(b))!.AsObject(); + var skip = new HashSet(ignore, StringComparer.Ordinal); + + var keys = new List(); + foreach (var entry in left) + { + if (skip.Contains(entry.Key)) continue; + if (entry.Value?.ToJsonString() != right[entry.Key]?.ToJsonString()) + keys.Add(entry.Key); + } + return keys; + } + /// A fresh, fully-detached copy of . public static MandoCodeConfig DeepClone(MandoCodeConfig source) { - var json = JsonSerializer.Serialize(source, WriteOptions); + var json = Serialize(source); var clone = JsonSerializer.Deserialize(json, ReadOptions) ?? throw new InvalidOperationException("Failed to clone MandoCodeConfig."); diff --git a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs index ea81623..6f2ac0d 100644 --- a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs +++ b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs @@ -1,4 +1,3 @@ -using System.Reflection; using MandoCode.Models; namespace MandoCode.Desktop.Services; @@ -8,9 +7,14 @@ namespace MandoCode.Desktop.Services; /// that calls . /// /// Settings are per-agent. Each tab runs on its own clone, and editing Settings while looking at -/// an agent changes that agent alone — live, for this session only. The file on disk is not "the -/// current settings"; it is , the starting point every NEW agent is seeded -/// from. "Make Default for New Agents" is the one action that writes it. +/// an agent changes that agent alone. ~/.mandocode/config.json is not "the current settings"; it is +/// , the starting point every NEW agent is seeded from. "Make Default for New +/// Agents" is the one action that writes it. +/// +/// An agent's own settings outlive the process: the first change to one snapshots it to +/// and it boots on those settings from then on (see +/// ). An agent that has never been configured has no file and is +/// re-seeded from Defaults on every load, so a default raised today still reaches it. /// /// One thing is deliberately app-wide rather than per-agent, because the machinery underneath /// it is: MCP servers — OS processes owned by a single shared McpClientManager. An agent can turn @@ -27,13 +31,6 @@ namespace MandoCode.Desktop.Services; /// public sealed class ConfigCoordinator { - // Reflected once. Using reflection rather than a hand-written field list means a config key - // added by the CLI harness is carried by "Make Default" without a change here. - private static readonly PropertyInfo[] SettableProperties = typeof(MandoCodeConfig) - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) - .ToArray(); - private readonly object _gate = new(); /// What a NEW agent starts on. Not the settings of any agent you're looking at. @@ -48,6 +45,65 @@ public sealed class ConfigCoordinator /// A fresh, fully-detached copy of the defaults for a new agent. public MandoCodeConfig CreateClone() => ConfigCloning.DeepClone(Defaults); + /// + /// The config an agent should BOOT on. An agent that has been configured before comes back on + /// its own saved settings; one that never has is seeded from the current defaults, so raising a + /// global default still reaches every agent the user never touched. + /// + /// Two things are re-sourced from even on a restored config, because + /// they are app-wide rather than per-agent: the MCP server SET (see + /// ) and secrets (see , which + /// is also why the saved file has no key in it to restore). + /// + public MandoCodeConfig CreateCloneFor(string persistKey) + { + lock (_gate) + { + var restored = AgentConfigStore.TryLoad(persistKey); + if (restored == null) return ConfigCloning.DeepClone(Defaults); + + restored.McpServers = ConfigCloning.DeepClone(Defaults).McpServers; + ApplySecretsTo(restored); + restored.ValidateAndClamp(); + return restored; + } + } + + /// + /// Copies the app-wide secrets onto one agent's clone. Secrets are deliberately absent from + /// per-agent files ( strips them), so this is what makes + /// Config.TavilyApiKey non-null on a restored agent — the value lives in exactly one + /// file on disk and is fanned out into memory from there. + /// + public void ApplySecretsTo(MandoCodeConfig target) => target.TavilyApiKey = Defaults.TavilyApiKey; + + /// + /// Mirrors a changed secret into every live agent — the same fan-out + /// does, for the same reason: the value is app-wide, so an + /// agent holding a stale copy would quietly use the old key. + /// + public void SyncSecretsToAgents() + { + lock (_gate) + { + foreach (var session in SessionsAccessor()) ApplySecretsTo(session.Config); + } + } + + /// + /// The inverse of — overwrites one agent's live config with the + /// current defaults, IN PLACE so every collaborator holding the same instance (AIService, + /// SkillLoader, McpApprovalGate) sees the new values without being rebuilt. + /// + /// is deliberately preserved: it is this tab's spoken + /// identity rather than a setting, and Defaults never carries one, so copying it across would + /// blank the agent's name out of its own system prompt. + /// + public void CopyDefaultsOnto(MandoCodeConfig target) + { + lock (_gate) ConfigCloning.CopyOnto(Defaults, target); + } + /// /// "Make Default for New Agents" — snapshots one agent's settings onto the defaults and /// persists them. Agents already open are untouched; they keep their own settings. @@ -56,13 +112,7 @@ public void SaveDefaultsFrom(MandoCodeConfig agentConfig) { lock (_gate) { - // Deep-clone first: reflection assigns reference-typed members straight across, and - // Defaults must not end up sharing the agent's List/Dictionary instances. - var snapshot = ConfigCloning.DeepClone(agentConfig); - foreach (var property in SettableProperties) - property.SetValue(Defaults, property.GetValue(snapshot)); - - Defaults.ValidateAndClamp(); + ConfigCloning.CopyOnto(agentConfig, Defaults); Defaults.Save(); } } diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs index 136507a..9b57dc2 100644 --- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs +++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs @@ -244,6 +244,7 @@ private static void DeleteFiles(string key) TranscriptJournal.Delete(key); ConversationLog.Delete(key); SessionHistoryStore.Delete(key); + AgentConfigStore.Delete(key); } private void Persist() diff --git a/src/MandoCode.Desktop/Services/SettingsScope.cs b/src/MandoCode.Desktop/Services/SettingsScope.cs new file mode 100644 index 0000000..c59ded5 --- /dev/null +++ b/src/MandoCode.Desktop/Services/SettingsScope.cs @@ -0,0 +1,147 @@ +using MandoCode.Models; +using MandoCode.Services; + +namespace MandoCode.Desktop.Services; + +/// +/// What a is editing. The form's controls, layout, validation +/// and staging are identical whichever it is — only where a save LANDS differs — so the form is +/// built once and hosted twice rather than duplicated. +/// +/// • — the rail page. Commits to ConfigCoordinator.Defaults: +/// what every NEW agent is seeded from. Nothing live is reconfigured. +/// • — the pane behind an agent's gear. Commits to that agent's +/// own config, reconfigures it live, and persists it (see ). +/// +/// The form never writes through this interface control-by-control: it edits a DRAFT clone and +/// hands the whole thing to when the user presses Save. An abandoned +/// draft is simply dropped. +/// +public interface ISettingsScope +{ + /// The live config being edited. Read-only from the form's side — it is the baseline + /// the draft is diffed against, not something the form mutates. + MandoCodeConfig Config { get; } + + /// Applies the whole draft and returns a line for the status area. + Task CommitAsync(MandoCodeConfig draft); + + /// Pulled models for the picker, or an empty list if Ollama can't be reached. + Task> ListModelsAsync(); + + /// True for the defaults scope only. App-wide settings — the Tavily key, agent + /// callsigns, the guided wizard — belong to the whole app rather than to one agent, so they + /// appear once, on the page that owns app-wide things. + bool ShowsAppWideSettings { get; } + + /// The sentence under the title telling the user what these settings govern. + string ScopeDescription { get; } + + /// + /// Config keys (JSON property names) this form does not own — edited on some other surface, so + /// they are neither counted as pending changes nor written by a save. The live value wins over + /// whatever the draft was cloned with, which is what stops a long-open form from undoing a + /// change made elsewhere while it sat there. + /// + IReadOnlyList KeysOwnedElsewhere { get; } +} + +/// The rail page: the starting point every new agent is seeded from. +public sealed class DefaultsSettingsScope(ConfigCoordinator configs) : ISettingsScope +{ + public MandoCodeConfig Config => configs.Defaults; + public bool ShowsAppWideSettings => true; + + public string ScopeDescription => + "The starting point for every new agent. Changing these does not touch an agent you have " + + "already configured — open that agent's own settings from its gear icon."; + + /// MCP servers have their own rail page. + public IReadOnlyList KeysOwnedElsewhere { get; } = ["mcpServers"]; + + public async Task CommitAsync(MandoCodeConfig draft) + { + // The MCP page may have edited the server set while this form sat open. It is app-wide and + // not ours to write, so the live list wins over the draft's snapshot of it. + draft.McpServers = configs.Defaults.McpServers; + ConfigCloning.CopyOnto(draft, configs.Defaults); + configs.SaveDefaults(); + // The Tavily key rides in the draft and is app-wide, so a save here has to reach the agents + // already open — they hold their own in-memory copy (AgentConfigStore never persists it). + configs.SyncSecretsToAgents(); + + // Probed, not connected: nothing here is talking to a model, but silently saving an endpoint + // that isn't there would only surface later as a broken agent. + var probe = await OllamaSetupHelper.ProbeAsync(configs.Defaults.OllamaEndpoint); + return probe.Ok + ? $"✓ Saved — new agents will start on {configs.Defaults.GetEffectiveModelName()}." + : $"Saved, but couldn't reach Ollama at {configs.Defaults.OllamaEndpoint}. New agents will try anyway."; + } + + public async Task> ListModelsAsync() + { + try + { + var probe = await OllamaSetupHelper.ProbeAsync(configs.Defaults.OllamaEndpoint); + if (!probe.Ok) return new List(); + return await OllamaSetupHelper.ListModelsAsync(probe.NormalizedUrl); + } + catch + { + return new List(); + } + } +} + +/// One agent's own settings, edited live from the pane behind its gear icon. +public sealed class AgentSettingsScope(AgentSession session, ConfigCoordinator configs) : ISettingsScope +{ + public AgentSession Session => session; + public ConfigCoordinator Configs => configs; + public MandoCodeConfig Config => session.Config; + public bool ShowsAppWideSettings => false; + + /// + /// Beyond the shared MCP list: an agent's endpoint is set when it launches, and its model comes + /// from the dropdown in its own header. Neither is editable on this pane, so neither may be + /// written back by it — otherwise switching models from the header while the pane sat open + /// would be silently undone by the next save here. + /// + public IReadOnlyList KeysOwnedElsewhere { get; } = + ["mcpServers", "ollamaEndpoint", "modelName", "modelPath"]; + + public string ScopeDescription => AgentConfigStore.Exists(session.PersistKey) + ? "These settings belong to this agent and are saved — it keeps them when you close and " + + "reopen it, and changes to the defaults for new agents no longer reach it." + : "This agent is still on the defaults for new agents. Save a change here and the settings " + + "become its own, kept and restored with it from then on."; + + public Task> ListModelsAsync() => session.Controller.ListModelsAsync(); + + public async Task CommitAsync(MandoCodeConfig draft) + { + var controller = session.Controller; + + // Everything this pane doesn't own comes back from the live config, so a save here writes + // only what the user could actually see and change. + draft.McpServers = Config.McpServers; + draft.OllamaEndpoint = Config.OllamaEndpoint; + draft.ModelName = Config.ModelName; + draft.ModelPath = Config.ModelPath; + + // In place: AIService, SkillLoader and McpApprovalGate all hold this same instance. + ConfigCloning.CopyOnto(draft, Config); + + // Kernel rebuild rather than reinitialize: nothing reachable from this pane can change the + // endpoint or model any more, so there is never a reconnect to do and the conversation + // always survives a save. + await controller.RefreshFromConfigAsync(); + var message = "✓ Saved — applies from this agent's next message."; + + // RefreshFromConfigAsync doesn't raise ConfigChanged (it isn't a user edit), so persistence + // is asked for explicitly here. Idempotent: it no-ops when nothing actually moved, and drops + // the agent's file entirely if the save left it identical to the defaults. + session.PersistConfigIfChanged(); + return message; + } +} diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 7e30364..43e8a79 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -169,6 +169,17 @@ private string ModelStatusDetail(string modelTag) /// Status bar refresh (connection, model, tokens). May fire on any thread. public event Action? StateChanged; + /// + /// This agent's config was deliberately changed — a Settings edit, /config set, a model switch, + /// or new connection settings. AgentSession listens and snapshots the config to disk, which is + /// what makes an agent's settings survive a close and reopen. + /// + /// Raised only for CHANGES the user asked for, never for the incidental config touches during + /// boot, and the listener re-checks against what it last wrote — so a spurious raise costs a + /// string compare, not a bad write. Prefer raising it to missing a mutation site. + /// + public event Action? ConfigChanged; + /// Plan execution progress: (completedSteps, totalSteps, active). public event Action? PlanProgressChanged; @@ -303,6 +314,21 @@ private async Task ApplyAgentSettingScopeAsync(ConfigKeySetter.ApplyScope scope) /// public void SaveAsDefaults() => _configs.SaveDefaultsFrom(_config); + /// + /// Reapplies the whole config to this agent after it was replaced wholesale ("Match Global + /// Defaults"). Takes the kernel-rebuild path rather than ReinitializeAsync so the CONVERSATION + /// SURVIVES: RefreshSettingsAsync rebuilds the system prompt, the agent, and the HTTP client + /// (so a changed endpoint, model, or context window all land) while leaving the history in + /// place. A model switch through SelectModelAsync would clear it — and would also re-derive the + /// context length for the new model tier, which would immediately push the agent back off the + /// defaults it was just matched to. + /// + public async Task RefreshFromConfigAsync() + { + await ApplyAgentSettingScopeAsync(ConfigKeySetter.ApplyScope.KernelRebuild); + StateChanged?.Invoke(); + } + // ============================================================ // Startup (port of InitializeConnectionAsync) // ============================================================ @@ -1411,6 +1437,7 @@ private async Task HandleConfigCommandAsync(string rawArgs) } _transcript.Append(_html.Dim("This agent only. Settings → \"Make Default for New Agents\" to keep it.")); StateChanged?.Invoke(); + ConfigChanged?.Invoke(); return; } @@ -1458,6 +1485,7 @@ private async Task HandleConfigCommandAsync(string rawArgs) } StateChanged?.Invoke(); + ConfigChanged?.Invoke(); return (true, message); } @@ -1623,6 +1651,9 @@ private async Task ApplyModelSwitchAsync(string modelTag) } StateChanged?.Invoke(); + // A pinned model (and the context length that came with it) is part of this agent's + // settings — persist it the same way a Settings edit is. + ConfigChanged?.Invoke(); } /// The outgoing conversation buffered on a model switch (or the live one, for a manual @@ -1862,6 +1893,8 @@ public async Task ApplyConnectionSettingsAsync(string endpoint, string? modelNam { _busy.Reset(); StateChanged?.Invoke(); + // Endpoint and model are this agent's, even though the onboarding flag above is not. + ConfigChanged?.Invoke(); } }