diff --git a/src/MandoCode.Desktop/Assets/web/terminal/terminal.js b/src/MandoCode.Desktop/Assets/web/terminal/terminal.js
index e2c009b..2d8c8a2 100644
--- a/src/MandoCode.Desktop/Assets/web/terminal/terminal.js
+++ b/src/MandoCode.Desktop/Assets/web/terminal/terminal.js
@@ -106,6 +106,16 @@
t.term.write("\r\n\x1b[38;5;244m" + (message || "[process exited]") + "\x1b[0m\r\n");
}
+ // A one-time informational line (e.g. the "install the CLI" nudge) — same dim styling as
+ // exited(), but semantically separate: it never touches tab/exit state, and it's written
+ // straight to this xterm instance's own buffer, never through the shell's stdin, so it can
+ // never be mistaken for something to run or interfere with whatever's actually in progress.
+ function note(id, message) {
+ const t = terms[id];
+ if (!t || !message) return;
+ t.term.write("\r\n\x1b[38;5;244m" + message + "\x1b[0m\r\n");
+ }
+
function setTheme(theme) {
for (const k in terms) terms[k].term.options.theme = makeTheme(theme);
}
@@ -122,6 +132,7 @@
case "focus": focus(m.id); break;
case "dispose": dispose(m.id); break;
case "exited": exited(m.id, m.message); break;
+ case "note": note(m.id, m.message); break;
case "theme": setTheme(m.theme); break;
}
});
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
index 04278f4..fe065d7 100644
--- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
@@ -347,6 +347,11 @@
a.file-link { color: var(--sky); text-decoration: none;
border-bottom: 1px dotted color-mix(in srgb, var(--sky) 55%, transparent); cursor: pointer; }
a.file-link:hover { color: var(--accent); border-bottom-color: var(--accent); }
+ /* Paths linkified out of assistant prose (transcript.js linkifyPaths) can be a full
+ absolute path — one unbroken 100-char token that would otherwise push the card wider
+ than the column. Break anywhere rather than overflow; only in .md, so the op cards'
+ short relative paths keep wrapping normally. */
+ .md a.file-link { overflow-wrap: anywhere; }
.op { margin: 2px 0; }
.op-head { font-weight: 600; }
.op-path { font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
index be523bb..ac4f634 100644
--- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
@@ -45,6 +45,93 @@
try { hljs.highlightElement(c); } catch (err) { }
});
}
+ // --- clickable file paths in assistant PROSE -----------------------------------------
+ // Operation cards get their links from TranscriptHtmlBuilder.FileLink, but the model also
+ // names files in ordinary sentences ("I've created xbox.txt at C:\...\xbox.txt"). Those
+ // are plain text, so this pass walks the text nodes of each new .md block and wraps any
+ // path-shaped run in the same a.file-link the op cards use — one shared click handler,
+ // one shared style, and the host already resolves relative-or-absolute (OpenTranscriptPath).
+ //
+ // Text nodes only: no HTML parsing, so a path can never be found inside a tag or an
+ // attribute, and the walker refuses to descend into
(code listings — linkifying
+ // there would fight highlight.js and read as noise) or an existing . Inline IS
+ // linkified, because a single backticked filename is how the model usually cites one.
+ // No single-letter extensions (.c/.h): a two-char floor is what stops the "*.com/…" and
+ // "*.co/…" segment of a URL from reading as a filename. Matched case-insensitively, so
+ // C:\…\XBOX.TXT works — safe only because of that floor.
+ const FILE_EXT = 'cs|csproj|sln|props|targets|razor|cshtml|xaml|axaml|js|mjs|cjs|jsx|ts|tsx|' +
+ 'json|jsonc|xml|xsd|resx|yml|yaml|toml|ini|cfg|conf|config|md|markdown|txt|log|csv|tsv|' +
+ 'html|htm|css|scss|sass|less|py|rb|go|rs|java|kt|swift|cpp|hpp|cc|php|lua|sql|' +
+ 'sh|bash|zsh|ps1|psm1|psd1|bat|cmd|env|lock|manifest|nuspec|dll|exe|pdb|' +
+ 'zip|tar|gz|7z|png|jpg|jpeg|gif|svg|webp|ico|pdf|docx|xlsx|pptx';
+ // Two alternatives, deliberately conservative — a false link on ordinary prose is worse
+ // than a missed one:
+ // 1. drive-rooted (C:\… , C:/…) or UNC (\\server\…). The prefix is unambiguous, so no
+ // extension is required and the whole run is taken. The leading (?"\'`|*?\\n]*[^\\s<>"\'`|*?.,;:!)\\]}\\n]' +
+ '|' +
+ '(?:(?:\\.{0,2}[\\\\/])?(?:[\\w.@+~-]+[\\\\/])+?[\\w.@+~-]+|[\\w@+~-][\\w.@+~-]*)' +
+ '\\.(?:' + FILE_EXT + ')(?![\\w./\\\\-])' +
+ ')',
+ 'gi');
+
+ function linkifyIn(md) {
+ // Collect first, mutate after — replacing a node mid-walk invalidates the TreeWalker.
+ const targets = [];
+ const walker = document.createTreeWalker(md, NodeFilter.SHOW_TEXT, {
+ acceptNode: function (n) {
+ if (!n.nodeValue || n.nodeValue.length < 3) return NodeFilter.FILTER_REJECT;
+ for (let p = n.parentElement; p && p !== md; p = p.parentElement) {
+ const t = p.tagName;
+ if (t === 'PRE' || t === 'A' || t === 'CODE' && p.closest('pre'))
+ return NodeFilter.FILTER_REJECT;
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ });
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
+ if (PATH_RE.test(n.nodeValue)) targets.push(n);
+
+ targets.forEach(function (node) {
+ const text = node.nodeValue;
+ const frag = document.createDocumentFragment();
+ let last = 0, m;
+ PATH_RE.lastIndex = 0;
+ while ((m = PATH_RE.exec(text)) !== null) {
+ if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
+ const a = document.createElement('a');
+ a.className = 'file-link';
+ a.href = '#';
+ a.title = 'Open in default app';
+ a.setAttribute('data-file', m[0]);
+ a.textContent = m[0]; // shown verbatim — the model's own wording is the record
+ frag.appendChild(a);
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
+ node.parentNode.replaceChild(frag, node);
+ });
+ }
+ // Runs after highlightNew() so code blocks are already tokenized (and skipped anyway).
+ function linkifyPaths() {
+ log.querySelectorAll('.md:not([data-fl])').forEach(function (md) {
+ md.setAttribute('data-fl', '1');
+ try { linkifyIn(md); } catch (err) { }
+ });
+ }
+
function doCopy(text, chip) {
// In the app, the host writes the clipboard (copy: message). In an EXPORTED transcript
// there is no webview bridge, so fall back to the browser clipboard API — file:// pages
@@ -292,6 +379,7 @@
wrap.innerHTML = html;
while (wrap.firstChild) placeChild(wrap.firstChild);
highlightNew();
+ linkifyPaths();
addCopyChips();
addReactionGhosts();
addCollapsers();
@@ -305,7 +393,11 @@
const link = e.target.closest('a[data-file]');
if (!link) return;
e.preventDefault();
- window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file'));
+ // Guarded like doCopy: an EXPORTED transcript has no webview bridge, and prose
+ // linkification puts these links on far more lines than the op cards did — an
+ // unguarded call would throw on every path click in a saved page.
+ if (window.chrome && window.chrome.webview)
+ window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file'));
});
// Interactive diff-card chips (delegated — survives transcript export, like the toggles).
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
index 77cf18d..edfa552 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
@@ -251,7 +251,14 @@ private async void ModelList_ItemClick(object sender, ItemClickEventArgs e)
private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
{
- var picker = new Windows.Storage.Pickers.FolderPicker();
+ var picker = new Windows.Storage.Pickers.FolderPicker
+ {
+ // Without this, every FolderPicker/FileOpenPicker/FileSavePicker in the app shares
+ // ONE "last visited folder" bucket — picking a folder anywhere (e.g. the Music
+ // flyout's playlist picker) silently becomes the starting point here too. Each
+ // call site across the app gets its own identifier for exactly this reason.
+ SettingsIdentifier = "ProjectRoot",
+ };
picker.FileTypeFilter.Add("*");
// Unpackaged apps must initialize pickers with the window handle.
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
index 9d0fa2d..e39552b 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
@@ -207,7 +207,10 @@ public async Task ExportTranscriptAsync()
var json = await core.ExecuteScriptAsync("document.documentElement.outerHTML");
var html = JsonSerializer.Deserialize(json) ?? "";
- var picker = new Windows.Storage.Pickers.FileSavePicker();
+ // SettingsIdentifier keeps this picker's "last visited folder" separate from every
+ // other picker in the app — see OpenFolderButton_Click below in this same class's
+ // sibling file, ChatTabView.Explorer.cs.
+ var picker = new Windows.Storage.Pickers.FileSavePicker { SettingsIdentifier = "TranscriptExport" };
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_owner));
picker.FileTypeChoices.Add("HTML page", new List { ".html" });
picker.SuggestedFileName = $"mandocode-transcript-{DateTime.Now:yyyy-MM-dd-HHmm}";
diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
index daab991..0ce72db 100644
--- a/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
+++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
@@ -51,11 +51,14 @@
-
+
+
+
+
The buffer as it was before the last assistant edit, or null when there's nothing to
- /// undo.
- private string? _undoBuffer;
+ /// Buffer right before the last assistant edit (the Undo target), or null once there's
+ /// nothing left to toggle.
+ private string? _editBefore;
- /// Caret to restore with it, so undo puts you back where you were.
- private int _undoCaret;
+ /// Buffer right after that edit landed (the Redo target).
+ private string? _editAfter;
- /// What the buffer looked like immediately AFTER that edit. The invalidation yardstick:
- /// content rather than a flag, because TextChanged arrives asynchronously and a flag set around
- /// the assignment can't be trusted to still mean anything by the time the event lands.
- private string _undoApplied = "";
+ /// Caret to restore with each endpoint, so toggling puts you back where you were.
+ private int _editBeforeCaret, _editAfterCaret;
- private string _undoWhat = "";
+ private string _editWhat = "";
private void CaptureAssistantUndo(string before, int caret, string what)
{
- _undoBuffer = before;
- _undoCaret = Math.Clamp(caret, 0, before.Length);
- _undoWhat = what;
+ _editBefore = before;
+ _editBeforeCaret = Math.Clamp(caret, 0, before.Length);
+ _editWhat = what;
+ _editAfter = null; // set once the edit actually lands, in MarkAssistantEditApplied
}
- /// Finishes an assistant edit: records the result as the undo yardstick, then runs the
+ /// Finishes an assistant edit: records the result as the Redo endpoint, then runs the
/// same dirty/autosave path typing does.
private void MarkAssistantEditApplied()
{
- _undoApplied = Editor.Text;
- RefreshUndoButton();
+ _editAfter = Editor.Text;
+ _editAfterCaret = Editor.SelectionStart;
+ RefreshUndoRedoButtons();
SetDirty(true);
_autosave.Stop();
@@ -177,44 +180,77 @@ private void MarkAssistantEditApplied()
/// Puts the note back as it was before the last Insert or Replace. Autosaves like any
/// other edit, so the file follows — an undo you have to remember to save is the same trap a jot
- /// pad exists to avoid.
+ /// pad exists to avoid. The Redo endpoint survives this: clicking Undo doesn't forget what it
+ /// undid, so Redo can put it right back.
public void UndoAssistantEdit()
{
- if (_undoBuffer == null || Current == null || Editor.IsReadOnly) return;
+ if (_editBefore == null || Current == null || Editor.IsReadOnly) return;
- var restored = _undoBuffer;
- var caret = _undoCaret;
- ClearAssistantUndo(); // single level: undoing is not itself undoable
-
- Editor.Text = restored;
- Editor.Select(Math.Clamp(caret, 0, Editor.Text.Length), 0);
+ Editor.Text = _editBefore;
+ Editor.Select(Math.Clamp(_editBeforeCaret, 0, Editor.Text.Length), 0);
Editor.Focus(FocusState.Programmatic);
+ RefreshUndoRedoButtons();
SetDirty(true);
_autosave.Stop();
_autosave.Start();
SetStatus("Undid the assistant's edit");
}
+ /// Puts the assistant's edit back after an Undo. Same toggle in the other direction —
+ /// the Undo endpoint survives this too, so you can go back and forth as many times as you like
+ /// between exactly these two states.
+ public void RedoAssistantEdit()
+ {
+ if (_editAfter == null || Current == null || Editor.IsReadOnly) return;
+
+ Editor.Text = _editAfter;
+ Editor.Select(Math.Clamp(_editAfterCaret, 0, Editor.Text.Length), 0);
+ Editor.Focus(FocusState.Programmatic);
+
+ RefreshUndoRedoButtons();
+ SetDirty(true);
+ _autosave.Stop();
+ _autosave.Start();
+ SetStatus("Redid the assistant's edit");
+ }
+
private void ClearAssistantUndo()
{
- _undoBuffer = null;
- _undoApplied = "";
- _undoWhat = "";
- RefreshUndoButton();
+ _editBefore = null;
+ _editAfter = null;
+ _editWhat = "";
+ RefreshUndoRedoButtons();
}
- private void RefreshUndoButton()
+ /// Shows whichever of Undo/Redo makes sense for the editor's CURRENT text — it can only
+ /// ever equal one of the two remembered endpoints (or neither, once typing has moved past both),
+ /// so the buttons are never both visible at once.
+ private void RefreshUndoRedoButtons()
{
- UndoButton.Visibility = _undoBuffer == null ? Visibility.Collapsed : Visibility.Visible;
- if (_undoBuffer == null) return;
+ bool atAfter = _editAfter != null && Editor.Text == _editAfter;
+ bool atBefore = _editBefore != null && Editor.Text == _editBefore;
- var label = _undoWhat == "replace" ? "Undo the assistant's replace" : "Undo the assistant's insert";
- ToolTipService.SetToolTip(UndoButton, label);
- Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(UndoButton, label);
+ UndoButton.Visibility = atAfter ? Visibility.Visible : Visibility.Collapsed;
+ RedoButton.Visibility = atBefore ? Visibility.Visible : Visibility.Collapsed;
+
+ var noun = _editWhat == "replace" ? "replace" : "insert";
+ if (atAfter)
+ {
+ var label = $"Undo the assistant's {noun}";
+ ToolTipService.SetToolTip(UndoButton, label);
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(UndoButton, label);
+ }
+ if (atBefore)
+ {
+ var label = $"Redo the assistant's {noun}";
+ ToolTipService.SetToolTip(RedoButton, label);
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(RedoButton, label);
+ }
}
private void Undo_Click(object sender, RoutedEventArgs e) => UndoAssistantEdit();
+ private void Redo_Click(object sender, RoutedEventArgs e) => RedoAssistantEdit();
// ---- opening / closing ----
@@ -314,10 +350,14 @@ public void Close()
private void Editor_TextChanged(object sender, TextChangedEventArgs e)
{
- // Any change that ISN'T the assistant edit we just applied retires the undo offer. Restoring
- // the pre-insert buffer after the user has typed on top of it would take their typing with
- // it, which is a worse outcome than not offering undo at all.
- if (_undoBuffer != null && Editor.Text != _undoApplied) ClearAssistantUndo();
+ // Any change that lands somewhere OTHER than the two endpoints Undo/Redo toggle between
+ // retires both — same reasoning either direction: restoring a remembered buffer after the
+ // user has typed on top of it would take their typing with it, which is worse than just not
+ // offering the toggle anymore. Landing ON one of the two endpoints (via Undo/Redo themselves,
+ // or by hand-typing your way back to one) is not a new edit and leaves the pair intact.
+ if ((_editBefore != null || _editAfter != null)
+ && Editor.Text != _editBefore && Editor.Text != _editAfter)
+ ClearAssistantUndo();
if (_suppressDirty || Current == null) return;
diff --git a/src/MandoCode.Desktop/Controls/TerminalPanel.xaml.cs b/src/MandoCode.Desktop/Controls/TerminalPanel.xaml.cs
index 7a3148f..3951c67 100644
--- a/src/MandoCode.Desktop/Controls/TerminalPanel.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/TerminalPanel.xaml.cs
@@ -157,6 +157,40 @@ private void AddTab(ShellSpec shell)
// Spin up the matching xterm instance and switch to it.
Post(new { type = "create", id, cols = 80, rows = 24 });
SwitchTo(id);
+
+ // Every new shell session gets a look — MandoCliDetector's own counter decides
+ // whether THIS is the one-in-N that actually shows anything.
+ MaybeShowCliHint(id);
+ }
+
+ /// Periodic, best-effort nudge toward the mandocode CLI — see MandoCliDetector for
+ /// the cadence and why every uncertain case stays quiet. Detection runs off the UI thread
+ /// (file/PATH checks); the note itself is written straight into the fresh shell's own xterm
+ /// buffer, never through its stdin, so it can't be mistaken for a command. The short delay
+ /// lets the shell print its own startup banner/prompt first, so this reads as "after the
+ /// prompt" instead of racing it.
+ private void MaybeShowCliHint(string id)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ if (!MandoCliDetector.ShouldShowHint()) return;
+
+ await Task.Delay(700);
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ if (_tabs.ContainsKey(id))
+ Post(new
+ {
+ type = "note",
+ id,
+ message = "(tip: mandocode CLI not found — install with: dotnet tool install --global MandoCode)"
+ });
+ });
+ }
+ catch { /* never disrupt the terminal over this */ }
+ });
}
private (Border header, TextBlock title) BuildTabHeader(string id, ShellSpec shell)
diff --git a/src/MandoCode.Desktop/MainWindow.Appearance.cs b/src/MandoCode.Desktop/MainWindow.Appearance.cs
index 28ed92a..1c9fe03 100644
--- a/src/MandoCode.Desktop/MainWindow.Appearance.cs
+++ b/src/MandoCode.Desktop/MainWindow.Appearance.cs
@@ -27,7 +27,9 @@ public sealed partial class MainWindow
private async void BgChoose_Click(object sender, RoutedEventArgs e)
{
- var picker = new Windows.Storage.Pickers.FileOpenPicker();
+ // SettingsIdentifier keeps this picker's "last visited folder" separate from every
+ // other picker in the app — see ChatTabView.Explorer.cs's OpenFolderButton_Click.
+ var picker = new Windows.Storage.Pickers.FileOpenPicker { SettingsIdentifier = "ChatBackground" };
// 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" })
diff --git a/src/MandoCode.Desktop/MainWindow.Music.cs b/src/MandoCode.Desktop/MainWindow.Music.cs
index 065a7ff..dffa122 100644
--- a/src/MandoCode.Desktop/MainWindow.Music.cs
+++ b/src/MandoCode.Desktop/MainWindow.Music.cs
@@ -190,7 +190,14 @@ private void MusicVolume_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.
private async void MusicAddPlaylist_Click(object sender, RoutedEventArgs e)
{
- var picker = new Windows.Storage.Pickers.FolderPicker();
+ var picker = new Windows.Storage.Pickers.FolderPicker
+ {
+ // See ChatTabView.Explorer.cs's OpenFolderButton_Click — every picker in the app
+ // needs its own SettingsIdentifier or they all share one "last visited folder"
+ // memory (this was the cause of the project-root picker opening onto whatever
+ // folder was last picked here).
+ SettingsIdentifier = "MusicPlaylist",
+ };
picker.FileTypeFilter.Add("*");
// Unpackaged apps must initialize pickers with the window handle.
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
@@ -206,6 +213,24 @@ private async void MusicAddPlaylist_Click(object sender, RoutedEventArgs e)
return;
}
+ // Validate BEFORE creating the junction — matches the engine's own scan
+ // (MusicPlayerService.DiscoverTracks uses SearchOption.TopDirectoryOnly, so nested
+ // MP3s don't count either). A folder with zero top-level MP3s must never become a
+ // junction: GetAvailableGenres() only lists genres that have at least one track, so
+ // an empty junction would be permanently invisible in this very combo box —
+ // unselectable, and therefore unremovable, since MusicRemovePlaylist_Click requires a
+ // combo selection. It would sit as a silent orphan under UserMusicPath, and re-picking
+ // the same folder would hit FindExistingFor above and claim "selected it" for a
+ // playlist that still can't be selected. Checking first means a bad folder leaves no
+ // trace, and the SAME clear answer every time you try it again — which is what "add a
+ // message when there aren't any MP3s" actually needs: this folder is never silently
+ // swallowed, so it can't look like the click did nothing.
+ if (!Directory.EnumerateFiles(folder.Path, "*.mp3", SearchOption.TopDirectoryOnly).Any())
+ {
+ ShowMusicHint($"No MP3s found directly inside “{folder.Path}” — files in subfolders don't count, they need to sit at the top level.");
+ return;
+ }
+
var name = MusicPlaylists.MakeUniqueName(_music.UserMusicPath, folder.Path);
try
{
@@ -223,19 +248,11 @@ private async void MusicAddPlaylist_Click(object sender, RoutedEventArgs e)
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);
+ // The pre-flight check above guarantees at least one track once rediscovery runs.
+ var tracks = rediscovered ? _music.GetAvailableTracks(name).Count : 0;
+ ShowMusicHint(rediscovered
+ ? $"Added “{name}” ({tracks} track{(tracks == 1 ? "" : "s")})."
+ : "Playlist added — restart MandoCode to see it.");
}
private async void MusicRemovePlaylist_Click(object sender, RoutedEventArgs e)
diff --git a/src/MandoCode.Desktop/MainWindow.Skills.cs b/src/MandoCode.Desktop/MainWindow.Skills.cs
index d5bf840..9940c86 100644
--- a/src/MandoCode.Desktop/MainWindow.Skills.cs
+++ b/src/MandoCode.Desktop/MainWindow.Skills.cs
@@ -378,7 +378,9 @@ private void SkillInstallMode_Changed(object sender, SelectionChangedEventArgs e
private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
{
- var picker = new Windows.Storage.Pickers.FolderPicker();
+ // SettingsIdentifier keeps this picker's "last visited folder" separate from every
+ // other picker in the app — see ChatTabView.Explorer.cs's OpenFolderButton_Click.
+ var picker = new Windows.Storage.Pickers.FolderPicker { SettingsIdentifier = "SkillInstallFolder" };
picker.FileTypeFilter.Add("*");
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
var folder = await picker.PickSingleFolderAsync();
@@ -387,7 +389,7 @@ private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
private async void SkillBrowseZip_Click(object sender, RoutedEventArgs e)
{
- var picker = new Windows.Storage.Pickers.FileOpenPicker();
+ var picker = new Windows.Storage.Pickers.FileOpenPicker { SettingsIdentifier = "SkillInstallZip" };
picker.FileTypeFilter.Add(".zip");
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
var file = await picker.PickSingleFileAsync();
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 7f4a49f..699bb73 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -92,12 +92,20 @@
+ While something is playing the glyph hides entirely and the gold equalizer below
+ takes its place, so the glyph itself is only ever the idle/paused state. -->
-
+
+
diff --git a/src/MandoCode.Desktop/Services/MandoCliDetector.cs b/src/MandoCode.Desktop/Services/MandoCliDetector.cs
new file mode 100644
index 0000000..88adcd5
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/MandoCliDetector.cs
@@ -0,0 +1,88 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Periodic, best-effort nudge toward installing the mandocode CLI (the dotnet global tool
+/// counterpart to this desktop app) — surfaced in the terminal, since that's the one place
+/// someone would actually run it. Fires every -th shell
+/// session (a persisted counter, so the cadence holds across app restarts too) for as long as
+/// the tool stays missing, and falls silent the moment it's found installed. Every uncertain
+/// case (can't read the counter, can't read PATH) resolves to "don't show": a missed hint costs
+/// nothing, a repeated one costs trust.
+///
+public static class MandoCliDetector
+{
+ // Tune to taste — a terminal can open many times in one sitting, so this wants to be
+ // large enough that the hint reads as an occasional aside, not a recurring ad.
+ private const int SessionsBetweenHints = 20;
+
+ private static string CounterPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MandoCode.Desktop", "cli-hint-counter");
+
+ /// Call once per new shell tab. True only on the one session out of every
+ /// that should actually show the hint — every other
+ /// call just advances the counter (or does nothing at all once the tool is installed).
+ public static bool ShouldShowHint()
+ {
+ try
+ {
+ if (IsInstalled()) return false;
+
+ int count = ReadCounter() + 1;
+ if (count < SessionsBetweenHints)
+ {
+ WriteCounter(count);
+ return false;
+ }
+
+ WriteCounter(0); // reset the cycle regardless of whether the caller can display it
+ return true;
+ }
+ catch { return false; } // uncertain -> stay quiet
+ }
+
+ /// True if the `mandocode` dotnet global tool is findable. Checks the fixed shim
+ /// path `dotnet tool install --global` always writes to first (fast, no PATH parsing for
+ /// the common case), then falls back to a PATH scan for anyone who installed it a
+ /// different way (built from source, a custom --tool-path). Any failure here is treated
+ /// as "installed" — see the class summary on the safe failure direction.
+ public static bool IsInstalled()
+ {
+ try
+ {
+ var shim = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".dotnet", "tools", "mandocode.exe");
+ if (File.Exists(shim)) return true;
+
+ var paths = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrEmpty(paths)) return false;
+ foreach (var dir in paths.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
+ {
+ try
+ {
+ if (File.Exists(Path.Combine(dir.Trim(), "mandocode.exe"))) return true;
+ }
+ catch { /* malformed PATH entry — skip it */ }
+ }
+ return false;
+ }
+ catch { return true; } // uncertain -> assume installed, stay quiet
+ }
+
+ private static int ReadCounter()
+ {
+ try { return int.TryParse(File.ReadAllText(CounterPath).Trim(), out var n) ? n : 0; }
+ catch { return 0; }
+ }
+
+ private static void WriteCounter(int value)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(CounterPath)!);
+ File.WriteAllText(CounterPath, value.ToString());
+ }
+ catch { /* best-effort — worst case the cadence drifts by a session or two */ }
+ }
+}