Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/MandoCode.Desktop/Assets/web/terminal/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
}
});
Expand Down
5 changes: 5 additions & 0 deletions src/MandoCode.Desktop/Assets/web/transcript/transcript.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
94 changes: 93 additions & 1 deletion src/MandoCode.Desktop/Assets/web/transcript/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre> (code listings — linkifying
// there would fight highlight.js and read as noise) or an existing <a>. Inline <code> 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 (?<![A-Za-z0-9]) is
// load-bearing: a drive letter is ONE letter, and without it the "s:/" inside
// "https://" parses as a drive and swallows the entire URL. The closing char class
// drops sentence punctuation so "…\xbox.txt." keeps the period out of the link.
// 2. everything else must END in a known extension, with (?![\w./\\-]) so a partial
// extension can't match inside a longer one (.cs must not fire inside ".csx"). That
// extension rule is what keeps prices ($19.99/mo), versions (v3.1,
// net8.0-windows10.0.19041.0), ratios (2/3, 17/19) and domains (pcguide.com,
// shattered.io) out — none of them end in a code/doc extension. The lookahead lives
// on THIS branch only; on branch 1 it would reject any path ending a sentence.
const PATH_RE = new RegExp(
'(?:' +
'(?<![A-Za-z0-9])(?:[A-Za-z]:[\\\\/]|\\\\\\\\)' +
'[^\\s<>"\'`|*?\\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
Expand Down Expand Up @@ -292,6 +379,7 @@
wrap.innerHTML = html;
while (wrap.firstChild) placeChild(wrap.firstChild);
highlightNew();
linkifyPaths();
addCopyChips();
addReactionGhosts();
addCollapsers();
Expand All @@ -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).
Expand Down
9 changes: 8 additions & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ public async Task ExportTranscriptAsync()
var json = await core.ExecuteScriptAsync("document.documentElement.outerHTML");
var html = JsonSerializer.Deserialize<string>(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<string> { ".html" });
picker.SuggestedFileName = $"mandocode-transcript-{DateTime.Now:yyyy-MM-dd-HHmm}";
Expand Down
20 changes: 15 additions & 5 deletions src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,28 @@
</StackPanel>

<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2" VerticalAlignment="Top">
<!-- Undo the assistant's last Insert/Replace. Appears only when there IS one to undo, and
goes away again the moment you type — see UndoAssistantEdit. It exists because
Ctrl+Z can't do this job: assigning Editor.Text resets the TextBox's undo history,
so the one edit the user didn't type by hand is the one the control can't reverse.
Gold, like the unsaved dot: this is the note in a state worth noticing. -->
<!-- Undo/Redo the assistant's last Insert/Replace — a single-level TOGGLE between the
buffer right before that edit and right after it (see NoteEditorPane's undo/redo
region). Exactly one of the two is ever visible, matching whichever endpoint the
editor's current text equals; both go away once you type past either endpoint.
They exist because Ctrl+Z can't do this job: assigning Editor.Text resets the
TextBox's own undo history, so the one edit the user didn't type by hand is the one
the control can't reverse on its own. Gold, like the unsaved dot: this is the note
in a state worth noticing. -->
<Button x:Name="UndoButton" Click="Undo_Click" Padding="6" Visibility="Collapsed"
Background="Transparent" BorderThickness="0"
AutomationProperties.Name="Undo the assistant's insert"
ToolTipService.ToolTip="Undo the assistant's insert">
<FontIcon Glyph="&#xE7A7;" FontSize="13"
Foreground="{StaticResource MandoGoldBrush}"/>
</Button>
<Button x:Name="RedoButton" Click="Redo_Click" Padding="6" Visibility="Collapsed"
Background="Transparent" BorderThickness="0"
AutomationProperties.Name="Redo the assistant's insert"
ToolTipService.ToolTip="Redo the assistant's insert">
<FontIcon Glyph="&#xE7A6;" FontSize="13"
Foreground="{StaticResource MandoGoldBrush}"/>
</Button>

<Button Padding="6" Background="Transparent" BorderThickness="0"
VerticalAlignment="Top" AutomationProperties.Name="Note options"
Expand Down
Loading
Loading