diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3931812..1071215 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ recorded by the `MandoCode` submodule.
## [Unreleased]
### Added
+- **Music player.** A music icon on the left rail opens a compact player: play/pause, next,
+ stop, volume, and a playlist picker. While music plays the rail icon becomes an animated
+ gold equalizer, and hovering it names the current track. **Add playlist** points at any
+ folder of MP3s on a local disk (a junction under `~\.mandocode\music` — nothing is copied,
+ and the CLI sees the same playlists); **Remove** deletes only the pointer, never the files,
+ and only ever offers itself on playlists added this way. Tracks auto-advance through the
+ playlist (an engine fix that also benefits the CLI — see the MandoCode changelog).
- **First-run guided setup.** A fresh install now walks through everything in the chat itself:
reach Ollama (with a one-click winget install when it's missing), start the daemon, and pick a
starter model from a curated list — cloud recommended, or local tiers with size and hardware
diff --git a/MandoCode b/MandoCode
index 7200b81..279ccb6 160000
--- a/MandoCode
+++ b/MandoCode
@@ -1 +1 @@
-Subproject commit 7200b81371c06762f5f521bb1361189ba9b86d5d
+Subproject commit 279ccb667cc1e86e8e08e4eeded9889a94272b33
diff --git a/src/MandoCode.Desktop/MainWindow.Music.cs b/src/MandoCode.Desktop/MainWindow.Music.cs
new file mode 100644
index 0000000..065a7ff
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Music.cs
@@ -0,0 +1,287 @@
+using MandoCode.Services;
+using MandoCode.Desktop.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Music flyout — UI over the harness's app-wide MusicPlayerService (one audio device).
+ // The service changes state on its own — auto-advance swaps CurrentTrack when a song
+ // ends, and a device failure stops playback with only AudioError to show for it — but
+ // exposes no events, so a 2-second poll watches for movement (WireMusicPolling).
+ // Playlist add/remove (directory junctions) lives in Services/MusicPlaylists.
+ // ============================================================
+
+ private readonly MusicPlayerService _music;
+
+ /// Guards the playlist combo's SelectionChanged while RefreshMusicUi repopulates it.
+ private bool _loadingMusicUi;
+
+ private void MusicFlyout_Opening(object sender, object e) => RefreshMusicUi();
+
+ /// Full rebuild: playlist list, selection, volume, empty state. Only for flyout
+ /// open and playlist add/remove — repopulating ItemsSource on every transport click would
+ /// churn selection (and close the dropdown if it's expanded under the user).
+ private void RefreshMusicUi()
+ {
+ _loadingMusicUi = true;
+ try
+ {
+ var genres = _music.GetAvailableGenres();
+ MusicGenreCombo.ItemsSource = genres;
+ MusicGenreCombo.SelectedItem = genres.FirstOrDefault(g => MusicPlaylists.SameName(g, _music.Genre))
+ ?? genres.FirstOrDefault();
+
+ var hasTracks = genres.Count > 0;
+ MusicGenreCombo.IsEnabled = hasTracks;
+ MusicPlayPauseButton.IsEnabled = hasTracks;
+ MusicVolumeSlider.Value = _music.Volume * 100;
+
+ MusicHintText.Visibility = Visibility.Collapsed;
+ if (!hasTracks)
+ ShowMusicHint($"No MP3s found. A playlist is just a folder of MP3s under {_music.UserMusicPath} (e.g. \\lofi).");
+
+ UpdateRemovePlaylistButton();
+ }
+ finally
+ {
+ _loadingMusicUi = false;
+ }
+ RefreshTransportState();
+ }
+
+ /// The parts that move during playback: track line, button states, play/pause
+ /// glyph, rail icon — and any AudioError, surfaced the moment the poll sees it.
+ private void RefreshTransportState()
+ {
+ MusicNextButton.IsEnabled = _music.IsPlaying;
+ MusicStopButton.IsEnabled = _music.IsPlaying || _music.IsPaused;
+
+ // IsPlaying and IsPaused are mutually exclusive in the service — paused means
+ // IsPlaying == false — so IsPlaying alone answers "is audio actually flowing".
+ MusicTrackText.Text = _music.CurrentTrack is { } track
+ ? (_music.IsPaused ? $"Paused — {track.Name}" : $"{track.Name} · {track.Genre}")
+ : "Nothing playing";
+ MusicPlayPauseIcon.Glyph = _music.IsPlaying ? "" : ""; // pause : play
+
+ if (_music.AudioError is { } error) ShowMusicHint(error);
+
+ UpdateMusicRailIcon();
+ }
+
+ // ============================================================
+ // Rail icon + poll
+ // ============================================================
+
+ private string? _musicTooltip;
+
+ /// The rail icon carries the state worth showing while the flyout is closed: an
+ /// animated gold equalizer while music plays (the glyph hides behind it), and a tooltip
+ /// naming the track — hover answers "what's this song" without opening anything. Runs on
+ /// the poll, so both only move on actual change: Begin() on a running storyboard visibly
+ /// restarts the bounce, and rewriting an open tooltip dismisses it.
+ private void UpdateMusicRailIcon()
+ {
+ var audible = _music.IsPlaying;
+ if (audible != (MusicEqPanel.Visibility == Visibility.Visible))
+ {
+ NavMusicIcon.Visibility = audible ? Visibility.Collapsed : Visibility.Visible;
+ MusicEqPanel.Visibility = audible ? Visibility.Visible : Visibility.Collapsed;
+ if (audible) MusicEqStoryboard.Begin();
+ else MusicEqStoryboard.Stop();
+ }
+
+ var tooltip = _music.CurrentTrack is { } track
+ ? (audible ? $"Playing — {track.Name}" : $"Paused — {track.Name}")
+ : "Music — background playlists while you work";
+ if (tooltip != _musicTooltip)
+ {
+ _musicTooltip = tooltip;
+ ToolTipService.SetToolTip(NavMusic, tooltip);
+ }
+ }
+
+ /// Kept in a field: a DispatcherQueueTimer referenced only by a local is
+ /// garbage-collected mid-flight and simply stops ticking. Stopped in MainWindow_Closed.
+ private Microsoft.UI.Dispatching.DispatcherQueueTimer? _musicPollTimer;
+ private string? _musicStateKey;
+
+ /// Watches for state the service changes on its own (auto-advance, device
+ /// failure) or that /music chat commands change from outside this flyout. Each tick
+ /// compares a small state key and touches the UI only when it moved — the open flyout
+ /// gets a transport refresh (so the track line follows an auto-advance), the closed one
+ /// just the rail icon. Called once from the constructor.
+ private void WireMusicPolling()
+ {
+ _musicPollTimer = _dispatcher.CreateTimer();
+ _musicPollTimer.Interval = TimeSpan.FromSeconds(2);
+ _musicPollTimer.Tick += (_, _) =>
+ {
+ var key = $"{_music.IsPlaying}|{_music.IsPaused}|{_music.CurrentTrack?.Name}|{_music.AudioError}";
+ if (key == _musicStateKey) return;
+ _musicStateKey = key;
+
+ if (MusicFlyout.IsOpen) RefreshTransportState();
+ else UpdateMusicRailIcon();
+ };
+ _musicPollTimer.Start();
+
+ UpdateMusicRailIcon(); // seed the icon and tooltip (the tooltip is only set here, not in XAML)
+ }
+
+ // ============================================================
+ // Transport + playlist selection
+ // ============================================================
+
+ private void MusicPlayPause_Click(object sender, RoutedEventArgs e)
+ {
+ if (_music.IsPlaying || _music.IsPaused) _music.TogglePause();
+ else _music.Play(MusicGenreCombo.SelectedItem as string);
+ RefreshTransportState();
+ }
+
+ private void MusicNext_Click(object sender, RoutedEventArgs e)
+ {
+ _music.NextTrack();
+ RefreshTransportState();
+ }
+
+ private void MusicStop_Click(object sender, RoutedEventArgs e)
+ {
+ _music.Stop();
+ RefreshTransportState();
+ }
+
+ private void MusicGenre_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_loadingMusicUi || MusicGenreCombo.SelectedItem is not string genre) return;
+
+ UpdateRemovePlaylistButton();
+
+ // Record the pick through the config owner even while idle. The flyout closes (and
+ // this combo unloads) around Add-playlist's folder picker, and RefreshMusicUi
+ // re-selects from music.Genre — without this the dropdown snaps back to the previous
+ // playlist on reopen. Saving also makes the pick survive a restart, like the volume
+ // already does via the service's own SavePreferences.
+ _configs.Defaults.Music.Genre = genre;
+ _configs.SaveDefaults();
+
+ // Switching playlist while playing jumps to it immediately; while idle it just
+ // becomes what the play button will start.
+ if (_music.IsPlaying || _music.IsPaused)
+ {
+ _music.Play(genre);
+ RefreshTransportState();
+ }
+ }
+
+ private void MusicVolume_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+ {
+ if (_loadingMusicUi) return;
+ _music.SetVolume((float)(e.NewValue / 100.0));
+ }
+
+ // ============================================================
+ // Playlist add / remove — thin UI over Services/MusicPlaylists
+ // ============================================================
+
+ private async void MusicAddPlaylist_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FolderPicker();
+ picker.FileTypeFilter.Add("*");
+ // Unpackaged apps must initialize pickers with the window handle.
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+
+ var folder = await picker.PickSingleFolderAsync();
+ if (folder == null) return;
+
+ // Re-adding a folder that's already a playlist selects the existing one.
+ if (MusicPlaylists.FindExistingFor(_music.UserMusicPath, folder.Path) is { } existing)
+ {
+ SelectPlaylist(existing);
+ ShowMusicHint($"“{existing}” already points at that folder — selected it.");
+ return;
+ }
+
+ var name = MusicPlaylists.MakeUniqueName(_music.UserMusicPath, folder.Path);
+ try
+ {
+ await MusicPlaylists.CreateAsync(_music.UserMusicPath, name, folder.Path);
+ }
+ catch (Exception ex)
+ {
+ ShowMusicHint($"Couldn't add that playlist: {ex.Message}");
+ return;
+ }
+
+ // Off the UI thread: the rescan walks every playlist folder, including junctions
+ // into arbitrarily large directories.
+ var rediscovered = await Task.Run(() => MusicPlaylists.TryRediscover(_music));
+ RefreshMusicUi();
+ SelectPlaylist(name);
+
+ string message;
+ if (!rediscovered)
+ {
+ message = "Playlist added — restart MandoCode to see it.";
+ }
+ else
+ {
+ var tracks = _music.GetAvailableTracks(name).Count;
+ message = tracks == 0
+ ? "Playlist added, but no MP3s sit at the top level of that folder."
+ : $"Added “{name}” ({tracks} track{(tracks == 1 ? "" : "s")}).";
+ }
+ ShowMusicHint(message);
+ }
+
+ private async void MusicRemovePlaylist_Click(object sender, RoutedEventArgs e)
+ {
+ if (MusicGenreCombo.SelectedItem is not string name) return;
+ // Pointers only, never a real folder of files — same gate as the button's visibility.
+ if (!MusicPlaylists.IsJunction(Path.Combine(_music.UserMusicPath, name))) return;
+
+ try
+ {
+ if (MusicPlaylists.SameName(_music.Genre, name) && (_music.IsPlaying || _music.IsPaused))
+ _music.Stop();
+ MusicPlaylists.Remove(_music.UserMusicPath, name);
+ }
+ catch (Exception ex)
+ {
+ ShowMusicHint($"Couldn't remove the playlist: {ex.Message}");
+ return;
+ }
+
+ var rediscovered = await Task.Run(() => MusicPlaylists.TryRediscover(_music));
+ RefreshMusicUi();
+ ShowMusicHint(rediscovered ? $"Removed “{name}” — its folder is untouched."
+ : "Playlist removed — restart MandoCode to update the list.");
+ }
+
+ /// Remove only offers itself for junction-backed playlists. Embedded genres have
+ /// no folder, and a real folder of files is not ours to delete from a flyout.
+ private void UpdateRemovePlaylistButton()
+ {
+ var visible = MusicGenreCombo.SelectedItem is string name
+ && MusicPlaylists.IsJunction(Path.Combine(_music.UserMusicPath, name));
+ MusicRemovePlaylistButton.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ /// Selects a playlist in the combo by name. Selection IS "loading": the change
+ /// handler records it as the service's genre and switches live playback to it.
+ private void SelectPlaylist(string name)
+ {
+ var match = MusicGenreCombo.Items.OfType().FirstOrDefault(g => MusicPlaylists.SameName(g, name));
+ if (match != null) MusicGenreCombo.SelectedItem = match;
+ }
+
+ private void ShowMusicHint(string text)
+ {
+ MusicHintText.Text = text;
+ MusicHintText.Visibility = Visibility.Visible;
+ }
+}
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 1e3058d..27e8b04 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -90,6 +90,101 @@
ToolTipService.ToolTip="Skills — reusable instructions the AI loads on demand">
+
+
+
();
_skillCoordinator = services.GetRequiredService();
_configs = services.GetRequiredService();
+ _music = services.GetRequiredService();
// Changed can fire on a background thread (a capture during a model switch).
_snapshotStore.Changed += () => OnUi(OnSnapshotsChanged);
_archive.Changed += () => OnUi(OnArchiveChanged);
@@ -104,6 +105,7 @@ public MainWindow()
// The editor writes note content; the panel only lists. One store, handed over once.
NoteEditor.Store = _notes;
WireNotesPanel();
+ WireMusicPolling();
// The first agent. Its whole service graph — AIService, approvals, transcript, token
// tracking — belongs to it alone, so opening a second tab can't disturb it.
@@ -163,7 +165,8 @@ private void MainWindow_Closed(object sender, WindowEventArgs args)
foreach (var tab in _tabs) tab.View.Shutdown();
_terminal?.ShutDown(); // kill any ConPTY shells so no processes leak
- try { App.Services.GetRequiredService().Dispose(); }
+ _musicPollTimer?.Stop(); // or its ticks keep touching a disposed service and closed XAML
+ try { _music.Dispose(); }
catch { /* nothing playing, or already disposed */ }
}
diff --git a/src/MandoCode.Desktop/Services/MusicPlaylists.cs b/src/MandoCode.Desktop/Services/MusicPlaylists.cs
new file mode 100644
index 0000000..d935f2a
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/MusicPlaylists.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+using System.Reflection;
+using MandoCode.Services;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// Junction-backed user playlists for the music player. A playlist is a directory junction
+/// under ~\.mandocode\music pointing at any local folder of MP3s: the harness's
+/// folder-scan discovery walks straight through junctions, so neither the engine nor the CLI
+/// (which shares the music root) needs a playlist concept of its own. Junctions rather than
+/// symlinks because they need no admin rights; the tradeoff is local volumes only — no UNC
+/// targets. WinUI-free so the pieces most likely to break on a pin roll live where a test
+/// can reach them.
+///
+public static class MusicPlaylists
+{
+ /// Case-insensitive name equality. Engine discovery lowercases user folder names
+ /// but leaves embedded genres raw, so every playlist-name comparison must ignore case —
+ /// one definition, used everywhere, instead of each call site remembering the rule.
+ public static bool SameName(string? a, string? b)
+ => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+
+ /// True when is a reparse point (junction). Reads the
+ /// link entry's own attributes — deliberately NOT Directory.Exists, which resolves
+ /// the target and can block on a junction into an unplugged drive.
+ public static bool IsJunction(string path)
+ {
+ try { return (new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0; }
+ catch { return false; }
+ }
+
+ /// Finds an existing playlist junction already pointing at
+ /// — compared by resolved path, not by name, so re-adding
+ /// a folder reuses its playlist instead of minting a numbered twin. Null when none.
+ public static string? FindExistingFor(string musicRoot, string targetFolder)
+ {
+ try
+ {
+ if (!Directory.Exists(musicRoot)) return null;
+ var target = CanonicalPath(targetFolder);
+ foreach (var dir in new DirectoryInfo(musicRoot).EnumerateDirectories())
+ {
+ var resolved = dir.ResolveLinkTarget(returnFinalTarget: true)?.FullName;
+ if (resolved != null && string.Equals(CanonicalPath(resolved), target, StringComparison.OrdinalIgnoreCase))
+ return dir.Name;
+ }
+ }
+ catch { /* unreadable entries just mean no match */ }
+ return null;
+ }
+
+ /// Playlist name from the target folder's own name — scrubbed by the same rules
+ /// as note titles, uniquified against the music root like snapshot titles ("beats",
+ /// "beats (2)", …).
+ public static string MakeUniqueName(string musicRoot, string targetFolder)
+ {
+ var baseName = NoteStore.SanitizeTitle(Path.GetFileName(Path.TrimEndingDirectorySeparator(targetFolder)));
+ if (baseName.Length == 0) baseName = "playlist";
+
+ var taken = Directory.Exists(musicRoot)
+ ? Directory.EnumerateDirectories(musicRoot).Select(Path.GetFileName).OfType().ToList()
+ : new List();
+ return SnapshotNaming.MakeUnique(baseName, taken);
+ }
+
+ /// Creates the junction via cmd's mklink /J — the only junction API that
+ /// needs neither admin rights nor P/Invoke. Throws with mklink's own message on failure.
+ public static async Task CreateAsync(string musicRoot, string name, string targetFolder)
+ {
+ Directory.CreateDirectory(musicRoot);
+ var link = Path.Combine(musicRoot, name);
+
+ var psi = new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{link}\" \"{targetFolder}\"")
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ };
+ using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Couldn't start cmd.exe.");
+ await proc.WaitForExitAsync();
+ if (proc.ExitCode != 0)
+ {
+ var err = (await proc.StandardError.ReadToEndAsync()).Trim();
+ throw new InvalidOperationException(err.Length > 0 ? err : $"mklink exited with code {proc.ExitCode}.");
+ }
+ }
+
+ /// Deletes only the junction — recursive: false on a reparse point removes
+ /// the link itself and structurally cannot touch the target folder's contents.
+ public static void Remove(string musicRoot, string name)
+ => Directory.Delete(Path.Combine(musicRoot, name), recursive: false);
+
+ /// The engine discovers tracks once, in its constructor, and exposes no re-scan.
+ /// Until it grows a public rediscover API (backlogged for the next pin roll), invoke the
+ /// private scan by reflection. False means "restart to see the change" — the honest
+ /// fallback if a future harness renames the method.
+ public static bool TryRediscover(MusicPlayerService music)
+ {
+ try
+ {
+ var discover = typeof(MusicPlayerService)
+ .GetMethod("DiscoverTracks", BindingFlags.Instance | BindingFlags.NonPublic);
+ if (discover == null) return false;
+ discover.Invoke(music, null);
+ return true;
+ }
+ catch { return false; }
+ }
+
+ private static string CanonicalPath(string path)
+ => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
+}