diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index c2c7fc8..345b8ea 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -218,6 +218,18 @@ public async Task InitializeAsync()
}
catch { }
+ // User-data host: serves the chat background image (see ThemeManager.SetChatBackground).
+ // Missing folder just means no background renders.
+ try
+ {
+ Directory.CreateDirectory(ThemeManager.UserDataFolder);
+ core.SetVirtualHostNameToFolderMapping(
+ "mandocode.userdata",
+ ThemeManager.UserDataFolder,
+ Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
+ }
+ catch { }
+
core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
}
catch (Exception ex)
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index f51fcf5..469d52c 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -414,25 +414,28 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs
index fb06934..f2dbe1a 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml.cs
+++ b/src/MandoCode.Desktop/MainWindow.xaml.cs
@@ -12,6 +12,7 @@
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
+using Microsoft.UI.Xaml.Media.Imaging;
using Microsoft.UI.Xaml.Shapes;
using Windows.ApplicationModel.DataTransfer;
using Windows.System;
@@ -152,9 +153,15 @@ public MainWindow()
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();
S_WindowOpacity.Value = ThemeManager.WindowOpacity * 100;
+ S_WindowOpacityLabel.Text = $"{(int)S_WindowOpacity.Value}%";
ApplyWindowOpacity(ThemeManager.WindowOpacity);
+ S_BgOpacity.Value = ThemeManager.ChatBackgroundOpacity * 100;
+ S_BgOpacityLabel.Text = $"{(int)S_BgOpacity.Value}%";
+ UpdateBgControls();
+ _appearanceReady = true; // opacity handlers may persist from here on
_dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
@@ -836,17 +843,80 @@ private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelect
// "Make Default for New Agents" always applies.
}
+ /// False until the constructor has loaded persisted appearance settings into the
+ /// sliders. The sliders' XAML default Values fire ValueChanged during InitializeComponent —
+ /// BEFORE ThemeManager.Initialize reads ui-settings.json — and a Save() in that window
+ /// overwrites the file with defaults (that bug ate users' saved background image).
+ private bool _appearanceReady;
+
private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
{
- // The slider's XAML Value fires this during InitializeComponent, before the
- // label (declared after it) exists — nothing to update yet, the constructor
- // applies the persisted opacity right after the tree is built.
- if (S_WindowOpacityLabel is null) return;
+ if (!_appearanceReady) return;
S_WindowOpacityLabel.Text = $"{(int)e.NewValue}%";
ThemeManager.SetWindowOpacity(e.NewValue / 100.0);
ApplyWindowOpacity(ThemeManager.WindowOpacity);
}
+ // ============================================================
+ // Chat background image (Appearance page)
+ // ============================================================
+
+ private async void BgChoose_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FileOpenPicker();
+ // Desktop apps must marry the picker to an HWND before use.
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+ foreach (var ext in new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" })
+ picker.FileTypeFilter.Add(ext);
+
+ var file = await picker.PickSingleFileAsync();
+ if (file == null) return;
+
+ ThemeManager.SetChatBackground(file.Path);
+ UpdateBgControls();
+ ApplyThemeToAllTabs();
+ }
+
+ private void BgClear_Click(object sender, RoutedEventArgs e)
+ {
+ ThemeManager.SetChatBackground(null);
+ UpdateBgControls();
+ ApplyThemeToAllTabs();
+ }
+
+ private void BgOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+ {
+ if (!_appearanceReady) return; // see _appearanceReady — a Save() here wipes settings
+ S_BgOpacityLabel.Text = $"{(int)e.NewValue}%";
+ BgPreviewImage.Opacity = e.NewValue / 100.0;
+ ThemeManager.SetChatBackgroundOpacity(e.NewValue / 100.0);
+ ApplyThemeToAllTabs(); // live preview while dragging — the script is tiny
+ }
+
+ private void UpdateBgControls()
+ {
+ var hasImage = ThemeManager.ChatBackgroundFile != null;
+ BgFileLabel.Text = hasImage ? "Image set ✓" : "No image set";
+ BgClearButton.IsEnabled = hasImage;
+ S_BgOpacity.IsEnabled = hasImage;
+ BgPreviewImage.Opacity = ThemeManager.ChatBackgroundOpacity;
+
+ // Decode from bytes, not from the file URI — a URI-sourced BitmapImage keeps the
+ // file open, and SetChatBackground must be able to overwrite it on the next pick.
+ BitmapImage? bmp = null;
+ if (hasImage)
+ {
+ try
+ {
+ using var ms = new MemoryStream(File.ReadAllBytes(ThemeManager.ChatBackgroundFile!));
+ bmp = new BitmapImage();
+ bmp.SetSource(ms.AsRandomAccessStream());
+ }
+ catch { bmp = null; /* unreadable image — preview just shows the theme colors */ }
+ }
+ BgPreviewImage.Source = bmp;
+ }
+
// WinUI has no Window.Opacity — whole-window translucency is a Win32 layered-window
// attribute on the HWND. At 100% the layered style is removed entirely so the
// compositor does no extra work for the default solid window.
@@ -880,6 +950,7 @@ private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs
{
if (_loadingSettings || 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}.";
}
diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs
index ae9ee3c..aaba31b 100644
--- a/src/MandoCode.Desktop/Services/ThemeManager.cs
+++ b/src/MandoCode.Desktop/Services/ThemeManager.cs
@@ -168,6 +168,15 @@ public static class ThemeManager
/// it via the Win32 layered-window alpha; this just owns the value and persistence.
public static double WindowOpacity { get; private set; } = 1.0;
+ /// Full path of the chat background image, or null when none is set. The picked
+ /// file is COPIED into (as chat-bg.<ext>) so the setting
+ /// survives the original moving; transcripts load it via the mandocode.userdata host.
+ public static string? ChatBackgroundFile { get; private set; }
+
+ /// Opacity of the chat background image layer only (0.05–1.0). Text never
+ /// fades — the slider dims the picture, not the conversation.
+ public static double ChatBackgroundOpacity { get; private set; } = 0.30;
+
/// Raised after a theme is applied so the window can retheme the WebView.
public static event Action? ThemeChanged;
@@ -177,6 +186,10 @@ public static class ThemeManager
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MandoCode.Desktop", "ui-settings.json");
+ /// Folder each tab's WebView2 serves as https://mandocode.userdata/ — holds the
+ /// copied chat background image (and the settings file itself, which is never requested).
+ public static string UserDataFolder => Path.GetDirectoryName(SettingsPath)!;
+
/// Loads the saved theme (or the default) and applies it. Call once from
/// the window constructor, before first render.
public static void Initialize(FrameworkElement root)
@@ -188,6 +201,12 @@ public static void Initialize(FrameworkElement root)
var saved = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath));
Current = UiTheme.All.FirstOrDefault(t => t.Name == saved?.Theme) ?? Current;
if (saved?.Opacity is > 0) WindowOpacity = Math.Clamp(saved.Opacity, 0.3, 1.0);
+ if (saved?.ChatBgOpacity is > 0) ChatBackgroundOpacity = Math.Clamp(saved.ChatBgOpacity, 0.05, 1.0);
+ if (!string.IsNullOrEmpty(saved?.ChatBackground))
+ {
+ var bg = Path.Combine(UserDataFolder, saved.ChatBackground);
+ if (File.Exists(bg)) ChatBackgroundFile = bg;
+ }
}
}
catch { /* unreadable settings file — fall back to the defaults */ }
@@ -209,13 +228,68 @@ public static void SetWindowOpacity(double value)
Save();
}
+ /// Copies the picked image into and remembers it,
+ /// or clears the background when is null. The caller
+ /// re-scripts open transcripts (MainWindow.ApplyThemeToAllTabs).
+ public static void SetChatBackground(string? sourcePath)
+ {
+ try
+ {
+ if (Directory.Exists(UserDataFolder))
+ foreach (var old in Directory.GetFiles(UserDataFolder, "chat-bg.*"))
+ File.Delete(old);
+ }
+ catch { /* an open WebView may briefly hold the old file — stale copies are harmless */ }
+
+ ChatBackgroundFile = null;
+ if (sourcePath != null)
+ {
+ try
+ {
+ Directory.CreateDirectory(UserDataFolder);
+ var dest = Path.Combine(UserDataFolder,
+ "chat-bg" + Path.GetExtension(sourcePath).ToLowerInvariant());
+ File.Copy(sourcePath, dest, overwrite: true);
+ ChatBackgroundFile = dest;
+ }
+ catch { /* unreadable source — behave as if cleared */ }
+ }
+ Save();
+ }
+
+ public static void SetChatBackgroundOpacity(double value)
+ {
+ ChatBackgroundOpacity = Math.Clamp(value, 0.05, 1.0);
+ Save();
+ }
+
+ /// CSS value for the transcript's --chat-bg-image variable: a cache-busted
+ /// virtual-host URL, or 'none' when no background is set.
+ public static string ChatBackgroundCssValue()
+ {
+ if (ChatBackgroundFile == null || !File.Exists(ChatBackgroundFile)) return "none";
+ var v = File.GetLastWriteTimeUtc(ChatBackgroundFile).Ticks;
+ return $"url(\"https://mandocode.userdata/{Path.GetFileName(ChatBackgroundFile)}?v={v}\")";
+ }
+
+ /// Invariant-culture string for --chat-bg-opacity (a comma decimal would be
+ /// silently invalid CSS on some locales).
+ public static string ChatBackgroundOpacityCss() =>
+ ChatBackgroundOpacity.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
+
private static void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(
- new UiSettings { Theme = Current.Name, Opacity = WindowOpacity }));
+ new UiSettings
+ {
+ Theme = Current.Name,
+ Opacity = WindowOpacity,
+ ChatBackground = ChatBackgroundFile == null ? null : Path.GetFileName(ChatBackgroundFile),
+ ChatBgOpacity = ChatBackgroundOpacity,
+ }));
}
catch { /* persistence is best-effort; the setting is still applied */ }
}
@@ -275,6 +349,8 @@ public static string BuildTranscriptScript(UiTheme t) =>
$"s.setProperty('--panel','{t.Panel}');" +
$"s.setProperty('--border','{t.Border}');" +
$"s.setProperty('--diffadd','{t.DiffAdd}');" +
+ $"s.setProperty('--chat-bg-image','{ChatBackgroundCssValue()}');" +
+ $"s.setProperty('--chat-bg-opacity','{ChatBackgroundOpacityCss()}');" +
"})();";
private static void SetBrush(ResourceDictionary res, string key, string hex) =>
@@ -299,5 +375,7 @@ private sealed class UiSettings
{
public string? Theme { get; set; }
public double Opacity { get; set; } = 1.0;
+ public string? ChatBackground { get; set; } // file name inside UserDataFolder
+ public double ChatBgOpacity { get; set; } = 0.30;
}
}
diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
index 6afa56b..1443c0d 100644
--- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
@@ -263,6 +263,8 @@ public static string BaseDocument(UiTheme theme) => $$"""
--panel: {{theme.Panel}};
--border: {{theme.Border}};
--diffadd: {{theme.DiffAdd}};
+ --chat-bg-image: {{ThemeManager.ChatBackgroundCssValue()}};
+ --chat-bg-opacity: {{ThemeManager.ChatBackgroundOpacityCss()}};
}
* { box-sizing: border-box; }
body {
@@ -270,6 +272,13 @@ public static string BaseDocument(UiTheme theme) => $$"""
font-family: "Segoe UI", sans-serif; font-size: 14px;
margin: 0; padding: 14px 18px 24px 18px; line-height: 1.5;
}
+ /* User-chosen chat background: a fixed full-bleed layer painted behind the log.
+ Only THIS layer fades with the appearance slider — text keeps full contrast,
+ and panels/code blocks keep their opaque theme backgrounds on top of it. */
+ #bg { position: fixed; inset: 0; z-index: -1; pointer-events: none;
+ background-image: var(--chat-bg-image); background-size: cover;
+ background-position: center; background-repeat: no-repeat;
+ opacity: var(--chat-bg-opacity); }
#log > * { margin-bottom: 8px; animation: rise 0.18s ease-out; }
@keyframes rise {
from { opacity: 0; transform: translateY(4px); }
@@ -474,6 +483,7 @@ the run is active and collapses once a non-operation block lands after it. */
+