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 @@
             
 
             
-            
+            
             
+            
 
             
             
+                 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. -->