(), new InvalidOperationException("boom")));
+
+ var result = await s.StreamAsync("hi", CancellationToken.None);
+
+ Assert.Equal("", result);
+ Assert.Contains(blocks, b => b.StartsWith("ERR:") && b.Contains("boom"));
+ }
+}
diff --git a/src/MandoCode.Desktop/App.xaml.cs b/src/MandoCode.Desktop/App.xaml.cs
index a5d9e27..53524e1 100644
--- a/src/MandoCode.Desktop/App.xaml.cs
+++ b/src/MandoCode.Desktop/App.xaml.cs
@@ -29,19 +29,7 @@ public App()
// Record any unhandled exception with its full stack to crash.log, so a UI-thread throw
// shows what actually failed instead of only the generated debugger-break in App.g.i.cs.
- UnhandledException += (_, e) =>
- {
- try
- {
- var dir = System.IO.Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "MandoCode.Desktop");
- System.IO.Directory.CreateDirectory(dir);
- System.IO.File.AppendAllText(System.IO.Path.Combine(dir, "crash.log"),
- $"[{DateTimeOffset.Now:O}] {e.Message}\n{e.Exception}\n\n");
- }
- catch { /* logging is best-effort — never mask the original failure */ }
- };
+ UnhandledException += (_, e) => CrashLog.Write("UnhandledException", e.Exception);
Services = BuildServices();
}
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
new file mode 100644
index 0000000..a75e456
--- /dev/null
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css
@@ -0,0 +1,452 @@
+ * { box-sizing: border-box; }
+ body {
+ background: var(--bg); color: var(--fg);
+ 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); }
+ to { opacity: 1; transform: none; }
+ }
+ /* E-ink / flat-motion themes: no fade-in, no hover transitions, no smooth scroll — the
+ transcript repaints instantly and stays still, the way an e-reader page does. The
+ attribute is set at build time and toggled live by ThemeManager.BuildTranscriptScript. */
+ html[data-flat] #log > * { animation: none; }
+ html[data-flat] *, html[data-flat] { transition: none !important; scroll-behavior: auto !important; }
+ /* E-ink background image: treat the (static) chat-background layer like a Kindle image —
+ grayscale + contrast + 1-bit Bayer ordered dithering into black/white halftone dots.
+ Applied ONLY to #bg (never the text) and ONLY under the flat/e-ink theme. The layer is
+ fixed and repaints once, so even this heavy filter costs nothing per frame. */
+ html[data-flat] #bg { filter: url(#eink); }
+ /* Color emoji is the loudest break in the paper illusion, so desaturate every emoji-bearing
+ surface to grayscale ink: the chrome (react ghost, reaction pills, picker) AND the inline
+ emoji in message text (.md) and user echoes. Scoped to the flat/e-ink theme only. Safe and
+ static — assistant turns are appended as COMPLETE blocks (ChatController flushes each turn
+ via AssistantCard; no token-by-token DOM streaming), so each subtree is filtered once on
+ append and never re-rasterized by later appends. Under e-ink every other glyph is already
+ ink-gray, so the only visible effect is draining the color out of emoji. */
+ html[data-flat] .react-ghost,
+ html[data-flat] .rx-pill,
+ html[data-flat] #rx-pop .rx,
+ html[data-flat] .md,
+ html[data-flat] .user-echo { filter: grayscale(1); }
+
+ /* ---- CRT picture-tube overlay (aperture-grille tube) ----------------------------------
+ Scoped to html[data-crt]. Drawn on two fixed, pointer-events:none pseudo-layers OVER the
+ transcript, so the "glass" sits in front of the text. EVERYTHING here is STATIC — no moving
+ scanline, no flicker (that is the continuous-repaint trap we keep avoiding); the tube look
+ is fixed gradients only, one paint. The set's native chrome outside the WebView is untouched,
+ exactly like a real TV where only the picture tube carries scanlines. */
+ html[data-crt] body {
+ /* phosphor bloom on every glyph — a tight bright core + a wider soft halo reads more
+ like real phosphor than one big blur (and keeps text legible). Static, so no per-frame
+ cost even though it rides the streaming-text repaint. */
+ text-shadow: 0 0 2px rgba(120, 210, 255, 0.55), 0 0 9px rgba(120, 210, 255, 0.42),
+ 0 0 18px rgba(120, 210, 255, 0.22);
+ }
+ html[data-crt] body::before {
+ content: ""; position: fixed; inset: 0; z-index: 9998; pointer-events: none;
+ background:
+ /* horizontal scanlines (4px period: 2px gap + 2px line) */
+ repeating-linear-gradient(to bottom,
+ rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
+ rgba(0,0,0,0.22) 2px, rgba(0,0,0,0.22) 4px),
+ /* aperture grille — faint vertical RGB stripes (the aperture-grille tell, not a dot mask) */
+ repeating-linear-gradient(to right,
+ rgba(255,0,64,0.05) 0, rgba(0,255,128,0.05) 1px,
+ rgba(64,128,255,0.05) 2px, rgba(0,0,0,0) 3px);
+ }
+ html[data-crt] body::after {
+ content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
+ background:
+ /* the two signature aperture-grille damper wires */
+ linear-gradient(to bottom,
+ transparent calc(33.3% - 1px), rgba(0,0,0,0.30) 33.3%, transparent calc(33.3% + 1px)),
+ linear-gradient(to bottom,
+ transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + 1px)),
+ /* tube-edge vignette */
+ radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%);
+ }
+ /* ---- Boxed messages (Appearance toggle, theme-agnostic) ---------------------------
+ Each prompt/response on its own card surface: hard message boundaries and skimmable
+ rhythm for long sessions, versus the default flat terminal look. Only theme variables,
+ so every palette works. Excluded under W98 — its bevelled message windows are bespoke. */
+ /* Frosted glass: cards are slightly translucent with a backdrop blur, so a chat
+ background image glows through without ever fighting the text (the blur is what
+ preserves contrast over busy wallpapers). Over a plain theme background the effect
+ degrades to near-solid — no image, no cost to readability. Blur is static compositing,
+ not per-frame work. */
+ html[data-cards]:not([data-win98]) .user-echo {
+ background: color-mix(in srgb, var(--panel) 82%, transparent);
+ backdrop-filter: blur(6px);
+ border: 1px solid var(--border); border-radius: 10px;
+ padding: 8px 12px; }
+ html[data-cards]:not([data-win98]) .assistant {
+ background: color-mix(in srgb, var(--panel) 82%, transparent);
+ backdrop-filter: blur(6px);
+ border: 1px solid var(--border); border-radius: 10px;
+ padding: 6px 12px 8px 12px; }
+ /* Cards sit on the panel color, so code wells inside switch to the bg color to stay
+ visually recessed (they normally use --panel against a --bg page). */
+ html[data-cards]:not([data-win98]) .md pre,
+ html[data-cards]:not([data-win98]) .md code { background: var(--bg); }
+
+ /* ---- Windows 98 chrome -----------------------------------------------------------
+ Scoped to html[data-win98]. The 3D language of 1998: silver surfaces, square corners,
+ two-tone bevels lit from the top-left (raised = chrome you can press, sunken = wells
+ that hold content), navy title-bar gradients, Tahoma, and none of the decoration the
+ era didn't have (radii, soft shadows). Colors come from the theme's CSS variables;
+ this block only reshapes geometry, bevels, and the title bars. All static — pairs
+ with the theme's FlatMotion, because nothing animated in 1998. */
+ html[data-win98] body { font-family: Tahoma, "MS Sans Serif", "Segoe UI", sans-serif;
+ /* THE desktop teal. Silver never filled a screen in 1998 — it sat in windows on this. */
+ background: #008080; padding: 12px 14px 20px 14px; }
+ /* Each MESSAGE is its own window on the desktop (not one giant expanding one): user
+ prompts are small silver windows; assistant responses are windows whose "MandoCode"
+ label becomes the navy title bar — the hover copy/react chips land on it like window
+ buttons. Status lines and tool ops sit directly on the teal like desktop icon labels,
+ with brightened colors (the theme's dark semantic hues are unreadable on teal).
+ (A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */
+ html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px;
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ html[data-win98] .assistant { background: var(--bg);
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ html[data-win98] .assistant-label {
+ background: linear-gradient(90deg, #000080, #1084D0); color: #FFFFFF;
+ padding: 3px 10px; margin-bottom: 0; font-weight: 700; }
+ html[data-win98] .assistant .md { padding: 2px 12px 8px 12px; }
+ html[data-win98] .line { color: #EAF6F4; }
+ html[data-win98] .line.info { color: #A8D8FF; }
+ html[data-win98] .line.success { color: #90EE90; }
+ html[data-win98] .line.warn { color: #FFE082; }
+ html[data-win98] .line.error { color: #FF9E8F; }
+ html[data-win98] .line.dim, html[data-win98] .op-meta, html[data-win98] .token-summary { color: #B8D8D4; }
+ html[data-win98] .op { color: #EAF6F4; }
+ html[data-win98] .op-path { color: #EAF6F4; }
+ html[data-win98] .op-head a.file-link { color: #AAD4FF; border-bottom-color: #AAD4FF; }
+ /* Op-head semantic colors (WebSearch/WebFetch/Write/Delete glyph classes) are theme-dark
+ hues built for silver — brighten them on the teal, same mapping as the .line variants. */
+ html[data-win98] .op-head.success { color: #90EE90; }
+ html[data-win98] .op-head.error, html[data-win98] .op-head.red { color: #FF9E8F; }
+ html[data-win98] .op-head.warn { color: #FFE082; }
+ html[data-win98] .op-head.info, html[data-win98] .op-head.sky { color: #A8D8FF; }
+ html[data-win98] .op-head.dim { color: #B8D8D4; }
+ /* Square EVERYTHING. */
+ html[data-win98] .panel, html[data-win98] .chip, html[data-win98] .tool-pill,
+ html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
+ html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
+ html[data-win98] .md pre, html[data-win98] .md code, html[data-win98] pre.mono-block,
+ html[data-win98] pre.raw, html[data-win98] .op-detail, html[data-win98] #rx-pop,
+ html[data-win98] .rx-pill, html[data-win98] #rx-pop .rx { border-radius: 0 !important; }
+ /* Raised bevel: anything button-like is a silver 3D control. */
+ html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
+ html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
+ html[data-win98] .tool-pill, html[data-win98] .chip, html[data-win98] .rx-pill {
+ background: var(--bg); color: #000;
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
+ }
+ /* ...and presses in like one. */
+ html[data-win98] .copy-chip:active, html[data-win98] .expand-btn:active,
+ html[data-win98] .web-toggle:active, html[data-win98] .dv-btn:active,
+ html[data-win98] .ue-toggle:active, html[data-win98] .react-ghost:active {
+ border-color: #404040 #FFFFFF #FFFFFF #404040;
+ }
+ /* Panels are little windows: raised silver frame + navy title-bar gradient. */
+ html[data-win98] .panel {
+ background: var(--bg);
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
+ }
+ html[data-win98] .panel-header {
+ background: linear-gradient(90deg, #000080, #1084D0);
+ color: #FFFFFF; border-bottom: none;
+ }
+ html[data-win98] .panel-header a.file-link { color: #FFFFFF; border-bottom-color: #9CC2E5; }
+ /* Content wells are sunken white, like every 98 text box and list view. */
+ html[data-win98] .md pre, html[data-win98] pre.cmd, html[data-win98] pre.cmd-out,
+ html[data-win98] pre.diff, html[data-win98] pre.mono-block, html[data-win98] pre.raw,
+ html[data-win98] .op-detail {
+ background: var(--panel);
+ border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080;
+ }
+ html[data-win98] .md code { background: var(--panel); border: 1px solid #808080; }
+ html[data-win98] .md pre code { border: none; }
+ /* 1998 had no soft shadows. */
+ html[data-win98] #rx-pop { box-shadow: none; background: var(--bg);
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ html[data-win98] .chip .dot, html[data-win98] .tool-pill .tp-dot { box-shadow: none; }
+ /* Plan/help tables become 98 list views: sunken white body, RAISED column headers —
+ the iconic Explorer detail. Row separators in dialog-face gray. */
+ html[data-win98] table.plan { background: var(--panel);
+ border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; }
+ html[data-win98] table.plan th { background: var(--bg); color: #000;
+ border: 1px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ html[data-win98] table.plan td { border-top: 1px solid #D4D0C8; }
+ /* Chunky classic scrollbars. */
+ html[data-win98] ::-webkit-scrollbar { width: 16px; height: 16px; }
+ html[data-win98] ::-webkit-scrollbar-track { background: #DFDFDF; }
+ html[data-win98] ::-webkit-scrollbar-thumb { background: var(--bg);
+ border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
+ html[data-win98] ::-webkit-scrollbar-corner { background: #DFDFDF; }
+
+ /* User prompts: gold marks the user's voice, at normal weight so an 8-line clamped
+ paste reads as text, not a block of emphasis. Only the sigil stays semibold. */
+ .user-echo { color: var(--gold); white-space: pre-wrap; margin-top: 14px; }
+ .ue-sigil { font-weight: 600; }
+ /* Long prompts clamp to ~8 lines (JS adds the class only when the echo is actually tall).
+ The fade is a mask on the text itself — not an overlay painted in a background color —
+ so it works over chat-background images and every theme. */
+ .user-echo.clamped { max-height: 11.5em; overflow: hidden;
+ -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent);
+ mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent); }
+ .ue-toggle { display: block; background: none; border: none; cursor: pointer;
+ color: var(--dim); font-size: 11px; font-family: "Segoe UI", sans-serif; padding: 1px 0; }
+ .ue-toggle:hover { color: var(--fg); }
+ .assistant { margin-top: 4px; position: relative; }
+ .assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; }
+ .md p { margin: 6px 0; }
+ .md pre {
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
+ padding: 10px 12px; overflow-x: auto; position: relative;
+ font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
+ }
+ .md code { font-family: "Cascadia Code", Consolas, monospace; background: var(--panel);
+ border-radius: 4px; padding: 1px 5px; font-size: 13px; }
+ .md pre code { background: none; padding: 0; }
+ .md table { border-collapse: collapse; margin: 8px 0; }
+ .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; }
+ a { color: var(--sky); }
+ .md h1, .md h2, .md h3 { color: var(--accent); margin: 12px 0 4px 0; }
+ .md ul, .md ol { margin: 4px 0; padding-left: 24px; }
+ .md blockquote { border-left: 3px solid var(--accent); margin: 6px 0; padding-left: 10px; color: var(--dim); }
+ .line { white-space: pre-wrap; }
+ .info { color: var(--sky); }
+ .success { color: var(--green); }
+ .warn { color: var(--gold); }
+ .error { color: var(--red); }
+ .dim { color: var(--dim); }
+ .sky { color: var(--sky); }
+ .red { color: var(--red); }
+ .token-summary { text-align: right; font-size: 12px; }
+
+ /* Status chips — compact pills for session/connection state. A CSS status dot
+ (crisp, theme-aware) replaces status emoji; state = ok | warn | err | neutral. */
+ /* Centered to match the tool pills: all system/status chrome sits centered, conversation stays left. */
+ .chip-row { margin: 2px 0; text-align: center; }
+ .chip { display: inline-flex; align-items: center; gap: 7px;
+ padding: 3px 12px; border-radius: 999px; font-size: 12.5px;
+ border: 1px solid var(--border); background: var(--panel);
+ /* Uniform floor so status pills line up — the "MCP / N connected" pill is the
+ widest of them, so shorter pills (model / ready) pad up to match. Longer
+ chips still grow past it. */
+ box-sizing: border-box; min-width: 190px; }
+ .chip .dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
+ background: var(--dim); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
+ .chip.ok .dot { background: var(--green);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 24%, transparent); }
+ .chip.warn .dot { background: var(--gold);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 24%, transparent); }
+ .chip.err .dot { background: var(--red);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 24%, transparent); }
+ .chip-val { color: var(--fg); font-weight: 600; }
+ .chip-key { color: var(--dim); }
+
+ /* Tool-call pills — STATIC (no animation, so they never cause continuous repaint). A rounded,
+ theme-colored chip with a monochrome glyph, matching the StatusChip family. */
+ /* Centered: tool pills are the assistant's machinery, not dialogue — centering (like Slack/Discord
+ system messages) keeps the left column a clean read and marks them as ambient activity.
+ display:flex + fit-content makes the chip block-level and shrink-wrapped so margin auto centers it. */
+ .tool-pill { display: flex; width: fit-content; align-items: center; gap: 8px; margin: 2px auto;
+ padding: 3px 12px; border-radius: 999px; font-size: 12px;
+ border: 1px solid var(--border); background: var(--panel); }
+ .tool-pill .tp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
+ background: var(--dim);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
+ .tool-pill .tp-label { color: var(--fg);
+ font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
+ overflow: hidden; }
+ .panel.red-border { border-color: var(--red); }
+ .panel-header { padding: 6px 12px; font-weight: 600; border-bottom: 1px solid var(--border);
+ font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
+ .panel-footer { padding: 4px 12px 8px 12px; color: var(--dim); font-size: 12px; }
+ pre.cmd, pre.cmd-out, pre.diff, pre.mono-block, pre.raw {
+ margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre;
+ font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
+ }
+ pre.mono-block, pre.raw { background: var(--panel); border: 1px solid var(--border);
+ border-radius: 8px; white-space: pre-wrap; }
+ .d-add { color: var(--diffadd); display: block; }
+ .d-rem { color: var(--red); display: block; }
+ .d-ctx { color: var(--dim); display: block; }
+
+ /* Collapsible long panels: a big write/diff/output otherwise fills the screen and forces
+ endless scrolling, so panel-hosted blocks taller than ~22% of the window collapse to that
+ preview height by default. A matching Expand/Collapse button sits in the top-RIGHT and
+ bottom-RIGHT corners (JS adds them only when a block is actually tall) so it's reachable
+ whether you're at the top or, after expanding, down at the bottom. The header and footer
+ pad on the right to clear the buttons. Pure class flip on click — no animation loop. */
+ .collapsible-panel { position: relative; }
+ .collapsible-panel > .panel-header,
+ .collapsible-panel > .panel-footer {
+ padding-right: 84px;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+ /* Reserve a bottom gutter so the bottom corner buttons never overlap the last line of a
+ footerless panel (e.g. command output). */
+ pre.collapsible { position: relative; padding-bottom: 34px; }
+ pre.collapsible.collapsed { max-height: 22vh; overflow-y: hidden; }
+ .collapse-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 44px;
+ pointer-events: none; background: linear-gradient(to bottom, transparent, var(--panel)); }
+ .expand-btn { position: absolute; top: 6px; z-index: 3; cursor: pointer;
+ background: var(--bg); color: var(--dim); border: 1px solid var(--border);
+ border-radius: 6px; padding: 2px 9px; font-size: 11px;
+ font-family: "Segoe UI", sans-serif; opacity: 0.9; }
+ .expand-btn.left { left: 6px; }
+ .expand-btn.right { right: 6px; }
+ .expand-btn.bottom { top: auto; bottom: 6px; }
+ .expand-btn:hover { color: var(--fg); border-color: var(--accent); opacity: 1; }
+
+ /* Web fetch/search previews: noisy reference text, hidden by default behind an inline Expand
+ chip on the op line. Expanding reveals the detail box, which reuses the corner Collapse
+ button (.expand-btn.right) so it can be closed from the window itself. */
+ .web-toggle { margin-left: 8px; cursor: pointer; vertical-align: baseline;
+ background: var(--bg); color: var(--dim); border: 1px solid var(--border);
+ border-radius: 6px; padding: 1px 8px; font-size: 11px; font-family: "Segoe UI", sans-serif; }
+ .web-toggle:hover { color: var(--fg); border-color: var(--accent); }
+ .web-detail { position: relative; margin-top: 4px; }
+ .web-detail[hidden] { display: none; }
+ .web-detail > .op-detail { margin-top: 0; }
+ /* Action chips on USER-requested diff cards (Changes-tab clicks): Undo posts to the host,
+ Clear removes the card. Floated right in the header; the collapsible-panel header's
+ right padding keeps them clear of the corner Expand button. */
+ .dv-actions { float: right; display: inline-flex; gap: 6px; }
+ .dv-btn { background: var(--bg); color: var(--dim); border: 1px solid var(--border);
+ border-radius: 6px; padding: 1px 8px; font-size: 11px;
+ font-family: "Segoe UI", sans-serif; cursor: pointer; }
+ .dv-btn:hover { color: var(--fg); border-color: var(--accent); }
+ 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); }
+ .op { margin: 2px 0; }
+ .op-head { font-weight: 600; }
+ .op-path { font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
+ .op-meta { color: var(--dim); font-size: 12px; }
+ .op-detail { margin-top: 4px; background: var(--panel); border: 1px solid var(--border);
+ border-radius: 8px; }
+ /* Prose tool output (web search/fetch): wrap to width, reading font, dimmed — reference material,
+ not a code block. Declared after pre.cmd-out so these win on shared properties. */
+ pre.op-prose { white-space: pre-wrap; word-break: break-word; overflow-x: hidden;
+ font-family: "Segoe UI", sans-serif; font-size: 12.5px; color: var(--dim); }
+ table.plan { border-collapse: collapse; width: 100%; }
+ table.plan th, table.plan td { border-top: 1px solid var(--border); padding: 5px 12px;
+ text-align: left; vertical-align: top; }
+ table.plan th { color: var(--dim); font-weight: 600; }
+ .nowrap { white-space: nowrap; }
+
+ /* Syntax highlighting: highlight.js token classes mapped onto the theme's CSS
+ variables, so code colors follow every theme (and survive live retheming). */
+ .hljs { background: transparent; color: var(--fg); }
+ .hljs-comment, .hljs-quote { color: var(--dim); font-style: italic; }
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag { color: var(--accent); }
+ .hljs-string, .hljs-regexp, .hljs-addition { color: var(--green); }
+ .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-meta, .hljs-built_in { color: var(--gold); }
+ .hljs-title, .hljs-section, .hljs-name, .hljs-title.function_, .hljs-title.class_ { color: var(--sky); }
+ .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-type { color: var(--sky); }
+ .hljs-deletion { color: var(--red); }
+ .hljs-emphasis { font-style: italic; }
+ .hljs-strong { font-weight: bold; }
+
+ /* Copy chips — appear on hover over code blocks and assistant messages. Label is
+ CSS generated content so it never pollutes the copied innerText. */
+ .copy-chip { position: absolute; top: 6px; right: 6px; z-index: 1; opacity: 0;
+ transition: opacity 0.12s; background: var(--bg); color: var(--dim);
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 9px;
+ font-size: 11px; font-family: "Segoe UI", sans-serif; cursor: pointer; }
+ .copy-chip::before { content: "Copy"; }
+ .copy-chip.copied::before { content: "Copied ✓"; }
+ .copy-chip.copied { color: var(--green); border-color: var(--green); }
+ .md pre:hover .copy-chip, .assistant:hover > .copy-chip { opacity: 1; }
+ .copy-chip:hover { color: var(--fg); border-color: var(--accent); }
+
+ /* Reactions, Teams-style. A ghosted add-reaction button fades in on hover next to the
+ copy chip; clicking it opens a floating picker card (mirrors the input box's emoji
+ flyout). Chosen reactions sit under the message as pills — no space is reserved
+ until one exists. Delivery to the model: ChatController.SubmitAsync. */
+ .react-ghost { position: absolute; top: 6px; right: 56px; z-index: 1; opacity: 0;
+ transition: opacity 0.12s; background: var(--bg); color: var(--dim);
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
+ font-size: 12px; cursor: pointer;
+ font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
+ .assistant:hover > .react-ghost { opacity: 0.55; }
+ .react-ghost:hover { opacity: 1 !important; border-color: var(--accent); color: var(--fg); }
+ /* The copy chip widens to "Copied ✓" for ~1.4s after a click; the ghost sits close
+ enough to collide, so it ducks out for the duration of the flash. */
+ .copy-chip.copied ~ .react-ghost { opacity: 0 !important; pointer-events: none; }
+ #rx-pop { position: absolute; z-index: 50; display: none; width: 316px;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
+ padding: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.45); }
+ #rx-pop .rx { background: none; border: 1px solid transparent; border-radius: 6px;
+ padding: 2px 5px; font-size: 17px; line-height: 22px; cursor: pointer;
+ font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
+ #rx-pop .rx:hover { background: var(--bg); border-color: var(--border); }
+ #rx-pop .rx.on { background: var(--bg); border-color: var(--accent); }
+ /* flex-wrap is the safety net: if emoji glyphs render wider than budgeted (font
+ version varies by Windows build), the row wraps inside the card instead of
+ bleeding past its border. */
+ #rx-pop .rx-quick { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; }
+ #rx-pop .rx-more-btn { margin-left: auto; background: none; border: none;
+ color: var(--dim); font-size: 12px; cursor: pointer; padding: 2px 6px; }
+ #rx-pop .rx-more-btn:hover { color: var(--fg); }
+ #rx-pop .rx-grid { display: none; flex-wrap: wrap; gap: 2px; margin-top: 6px;
+ padding-top: 6px; border-top: 1px solid var(--border); max-height: 156px;
+ overflow-y: auto; }
+ .rx-tray { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
+ .rx-pill { background: var(--panel); border: 1px solid var(--accent); border-radius: 999px;
+ padding: 1px 9px; font-size: 13px; line-height: 19px; cursor: pointer;
+ font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
+ .rx-pill:hover { border-color: var(--dim); opacity: 0.85; }
+
+ /* Consecutive operation cards group into a collapsible run; it stays open while
+ the run is active and collapses once a non-operation block lands after it. */
+ details.op-group { margin: 2px 0; }
+ details.op-group summary { color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }
+ details.op-group summary:hover { color: var(--fg); }
+ details.op-group > .op { margin-left: 16px; }
+
+ /* Jump-to-bottom pill — shows when scrolled away from the live end of the chat. */
+ #jump-pill { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
+ display: none; z-index: 40; background: var(--panel); color: var(--fg);
+ border: 1px solid var(--accent); border-radius: 999px; padding: 6px 14px;
+ font-size: 12px; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
+
+ /* In-chat find bar (Ctrl+F while the transcript has focus). */
+ #findbar { position: fixed; top: 10px; right: 16px; z-index: 60; display: none;
+ align-items: center; gap: 6px; background: var(--panel);
+ border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
+ #findbar input { background: var(--bg); color: var(--fg); border: 1px solid var(--border);
+ border-radius: 6px; padding: 3px 8px; font-size: 12px; width: 180px; outline: none; }
+ #findbar .find-count { color: var(--dim); font-size: 11px; min-width: 44px; text-align: center; }
+ #findbar button { background: none; border: none; color: var(--dim); cursor: pointer;
+ font-size: 12px; padding: 2px 6px; }
+ #findbar button:hover { color: var(--fg); }
+ mark.find-hit { background: var(--gold); color: #000; border-radius: 2px; }
+ mark.find-hit.find-current { background: var(--accent); color: #fff; }
+
+ /* Scrollbars — Chromium's stock chrome ignores the theme; restyle every scroll surface
+ (page, code blocks, reaction picker grid) to match it. */
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px;
+ border: 2px solid transparent; background-clip: padding-box; }
+ ::-webkit-scrollbar-thumb:hover { background-color: var(--dim); }
+ ::-webkit-scrollbar-corner { background: transparent; }
+ #rx-pop .rx-grid::-webkit-scrollbar { width: 7px; }
diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
new file mode 100644
index 0000000..be523bb
--- /dev/null
+++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js
@@ -0,0 +1,464 @@
+ const log = document.getElementById('log');
+ if (window.hljs) hljs.configure({ ignoreUnescapedHTML: true });
+
+ // --- append pipeline: group consecutive op cards, stamp timestamps ---
+ function groupSummary(d) {
+ const n = d.querySelectorAll(':scope > .op').length;
+ d.querySelector('summary').textContent = '⚙ ' + n + ' operation' + (n === 1 ? '' : 's');
+ }
+ function placeChild(c) {
+ if (c.nodeType !== 1) { log.appendChild(c); return; }
+ if (c.classList.contains('op')) {
+ const prev = log.lastElementChild;
+ if (prev && prev.tagName === 'DETAILS' && prev.classList.contains('op-group') && prev.hasAttribute('open')) {
+ prev.appendChild(c);
+ groupSummary(prev);
+ return;
+ }
+ if (prev && prev.classList.contains('op')) {
+ const d = document.createElement('details');
+ d.className = 'op-group';
+ d.setAttribute('open', '');
+ d.appendChild(document.createElement('summary'));
+ log.insertBefore(d, prev);
+ d.appendChild(prev);
+ d.appendChild(c);
+ groupSummary(d);
+ return;
+ }
+ log.appendChild(c);
+ return;
+ }
+ const last = log.lastElementChild;
+ if (last && last.tagName === 'DETAILS' && last.classList.contains('op-group'))
+ last.removeAttribute('open'); // run over — collapse the group
+ if (c.classList.contains('assistant') || c.classList.contains('user-echo'))
+ c.title = new Date().toLocaleTimeString();
+ log.appendChild(c);
+ }
+
+ // --- syntax highlighting + copy chips, applied to new nodes only ---
+ function highlightNew() {
+ if (!window.hljs) return;
+ log.querySelectorAll('.md pre code:not([data-hl])').forEach(function (c) {
+ c.setAttribute('data-hl', '1');
+ try { hljs.highlightElement(c); } 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
+ // are a secure context in Chromium/Firefox, and this runs on a user gesture.
+ if (window.chrome && window.chrome.webview)
+ window.chrome.webview.postMessage('copy:' + text);
+ else if (navigator.clipboard)
+ navigator.clipboard.writeText(text).catch(function () { });
+ chip.classList.add('copied');
+ setTimeout(function () { chip.classList.remove('copied'); }, 1400);
+ }
+ function addCopyChips() {
+ log.querySelectorAll('.md pre:not([data-copy])').forEach(function (pre) {
+ pre.setAttribute('data-copy', '1');
+ const chip = document.createElement('button');
+ chip.className = 'copy-chip';
+ pre.appendChild(chip); // click is handled by the delegated .copy-chip handler
+ });
+ log.querySelectorAll('.assistant:not([data-copy])').forEach(function (card) {
+ card.setAttribute('data-copy', '1');
+ if (!card.querySelector('.md')) return;
+ const chip = document.createElement('button');
+ chip.className = 'copy-chip';
+ card.appendChild(chip);
+ });
+ }
+ // Delegated so copy still works in exported transcripts (see the toggle handlers below).
+ document.addEventListener('click', function (e) {
+ const chip = e.target.closest('.copy-chip');
+ if (!chip) return;
+ e.stopPropagation();
+ const pre = chip.closest('pre');
+ let text = '';
+ if (pre) {
+ const code = pre.querySelector('code');
+ text = code ? code.innerText : pre.innerText;
+ } else {
+ const card = chip.closest('.assistant');
+ const md = card && card.querySelector('.md');
+ if (md) text = md.innerText;
+ }
+ doCopy(text, chip);
+ });
+
+ // --- emoji reactions on assistant responses: hover ghost → picker card → pills ---
+ // Toggling posts react:/unreact: with a JSON payload; the snippet lets the preamble
+ // on the user's next turn say WHICH response was reacted to.
+ const RX_QUICK = ['👍', '👎', '❤️', '🔥', '🎉', '🤔', '😂'];
+ const RX_MORE = ['😀', '😄', '😊', '😉', '😍', '🥰', '😎', '🤓', '🙃', '😅', '😬', '😭',
+ '🥳', '🤯', '😴', '🙄', '😤', '😱', '🫠', '🤗', '🫡', '👌', '🙏', '👏', '💪', '🤝',
+ '✌️', '🤞', '👀', '🧠', '💯', '✨', '🚀', '🎯', '💡', '⚡', '⭐', '💔', '✅', '❌',
+ '⚠️', '❓', '❗', '💬', '🐛', '🔧', '🔒', '🔑', '📝', '📌', '📁', '🖥️', '☕', '🍕',
+ '🎮', '🤖'];
+ let rxSeq = 0;
+ let rxCard = null; // the card the open picker targets
+
+ const rxPop = document.createElement('div');
+ rxPop.id = 'rx-pop';
+ document.body.appendChild(rxPop);
+
+ function rxSnippet(card) {
+ const md = card.querySelector('.md');
+ return (md ? md.innerText : '').trim().replace(/\s+/g, ' ').slice(0, 80);
+ }
+ function rxPillFor(card, emoji) {
+ const tray = card.querySelector('.rx-tray');
+ if (!tray) return null;
+ return Array.prototype.find.call(tray.children, function (p) { return p.textContent === emoji; });
+ }
+ // Toggle a reaction on a card: pill tray + picker highlight + postMessage, all in one place.
+ function rxToggle(card, emoji) {
+ const existing = rxPillFor(card, emoji);
+ if (existing) {
+ existing.remove();
+ const tray = card.querySelector('.rx-tray');
+ if (tray && !tray.children.length) tray.remove();
+ window.chrome.webview.postMessage('unreact:' +
+ JSON.stringify({ id: card.dataset.rxId, emoji: emoji, snippet: '' }));
+ } else {
+ let tray = card.querySelector('.rx-tray');
+ if (!tray) {
+ tray = document.createElement('div');
+ tray.className = 'rx-tray';
+ card.appendChild(tray);
+ }
+ const pill = document.createElement('button');
+ pill.className = 'rx-pill';
+ pill.textContent = emoji;
+ pill.title = 'Click to remove reaction';
+ pill.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ rxToggle(card, emoji);
+ });
+ tray.appendChild(pill);
+ window.chrome.webview.postMessage('react:' +
+ JSON.stringify({ id: card.dataset.rxId, emoji: emoji, snippet: rxSnippet(card) }));
+ }
+ // Picking (or un-picking) from the open picker dismisses it — one-shot action,
+ // like Teams/Slack. Multiple reactions = reopen; chosen ones show highlighted.
+ if (rxPop.style.display === 'block' && rxCard === card) closeRxPop();
+ }
+ function rxChip(parent, emoji) {
+ const b = document.createElement('button');
+ b.className = 'rx' + (rxCard && rxPillFor(rxCard, emoji) ? ' on' : '');
+ b.textContent = emoji;
+ b.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ rxToggle(rxCard, emoji);
+ });
+ parent.appendChild(b);
+ }
+ function openRxPop(card, anchor) {
+ rxCard = card;
+ rxPop.innerHTML = '';
+ const quick = document.createElement('div');
+ quick.className = 'rx-quick';
+ RX_QUICK.forEach(function (e) { rxChip(quick, e); });
+ const moreBtn = document.createElement('button');
+ moreBtn.className = 'rx-more-btn';
+ moreBtn.textContent = 'More ▾';
+ quick.appendChild(moreBtn);
+ rxPop.appendChild(quick);
+ const grid = document.createElement('div');
+ grid.className = 'rx-grid';
+ RX_MORE.forEach(function (e) { rxChip(grid, e); });
+ rxPop.appendChild(grid);
+ moreBtn.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ const opening = grid.style.display !== 'flex';
+ grid.style.display = opening ? 'flex' : 'none';
+ moreBtn.textContent = opening ? 'Less ▴' : 'More ▾';
+ });
+ // Anchor under the ghost button, right-aligned; flip above when near the viewport bottom.
+ rxPop.style.display = 'block';
+ const r = anchor.getBoundingClientRect();
+ const pw = rxPop.offsetWidth, ph = rxPop.offsetHeight;
+ const left = Math.max(8, Math.min(r.right - pw, window.innerWidth - pw - 8)) + window.scrollX;
+ let top = r.bottom + 6 + window.scrollY;
+ if (r.bottom + ph + 12 > window.innerHeight) top = Math.max(window.scrollY + 8, r.top - ph - 6 + window.scrollY);
+ rxPop.style.left = left + 'px';
+ rxPop.style.top = top + 'px';
+ }
+ function closeRxPop() { rxPop.style.display = 'none'; rxCard = null; }
+ document.addEventListener('click', function (e) {
+ if (rxPop.style.display === 'block' && !rxPop.contains(e.target)) closeRxPop();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') closeRxPop();
+ });
+
+ function addReactionGhosts() {
+ log.querySelectorAll('.assistant:not([data-rx])').forEach(function (card) {
+ card.setAttribute('data-rx', '1');
+ card.dataset.rxId = String(++rxSeq);
+ const ghost = document.createElement('button');
+ ghost.className = 'react-ghost';
+ ghost.textContent = '🙂+';
+ ghost.title = 'React to this response';
+ ghost.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ if (rxPop.style.display === 'block' && rxCard === card) { closeRxPop(); return; }
+ openRxPop(card, ghost);
+ });
+ card.appendChild(ghost);
+ });
+ }
+
+ // --- collapse long diff/output panels to a preview; corner buttons maximize/minimize ---
+ // Only panel-hosted blocks (diffs, command output, folder-delete listings) taller than
+ // ~22% of the window get collapsed. A matching Expand/Collapse button is placed in the top-RIGHT
+ // and bottom-RIGHT corners so it's reachable from the top or — after expanding down — the bottom.
+ function setCollapsed(pre, collapsed) {
+ pre.classList.toggle('collapsed', collapsed);
+ const panel = pre.closest('.panel');
+ if (!panel) return;
+ const fade = panel.querySelector('.collapse-fade');
+ if (fade) fade.style.display = collapsed ? 'block' : 'none';
+ panel.querySelectorAll('.expand-btn').forEach(function (b) {
+ b.textContent = collapsed ? '⤢ Expand' : '⤡ Collapse';
+ });
+ }
+ function addCollapsers() {
+ log.querySelectorAll('pre.diff:not([data-collapse]), pre.cmd-out:not([data-collapse])').forEach(function (pre) {
+ pre.setAttribute('data-collapse', '1');
+ const panel = pre.closest('.panel');
+ if (!panel) return; // only panel-hosted blocks
+ if (pre.scrollHeight <= window.innerHeight * 0.22 + 40) return; // short enough already
+ pre.classList.add('collapsible');
+ panel.classList.add('collapsible-panel');
+ const fade = document.createElement('div');
+ fade.className = 'collapse-fade';
+ pre.appendChild(fade);
+ ['right', 'right bottom'].forEach(function (side) {
+ const b = document.createElement('button');
+ b.className = 'expand-btn ' + side;
+ b.title = 'Maximize / minimize this block';
+ panel.appendChild(b); // click is handled by the delegated .expand-btn handler
+ });
+ setCollapsed(pre, true); // start minimized
+ });
+ }
+
+ // --- clamp long user prompts: a big pasted prompt (log, file contents) otherwise
+ // dominates the scrollback, so echoes taller than ~9 lines clamp to ~8 with a toggle ---
+ function addEchoClamps() {
+ log.querySelectorAll('.user-echo:not([data-clamp])').forEach(function (echo) {
+ echo.setAttribute('data-clamp', '1');
+ const lh = parseFloat(getComputedStyle(echo).lineHeight) || 20;
+ if (echo.scrollHeight <= lh * 9 + 6) return; // short enough — no chrome
+ // Count hidden lines while the echo is still unclamped; ~8 lines stay visible.
+ const hidden = Math.max(1, Math.round(echo.scrollHeight / lh) - 8);
+ echo.classList.add('clamped');
+ const btn = document.createElement('button');
+ btn.className = 'ue-toggle';
+ // The expanded label lives in a data attribute (not a closure) so it survives
+ // outerHTML serialization when the transcript is exported.
+ btn.dataset.more = 'Show more (' + hidden + ' more line' + (hidden === 1 ? '' : 's') + ')';
+ btn.textContent = btn.dataset.more;
+ echo.after(btn);
+ });
+ }
+ // Toggles are DELEGATED document handlers, not per-button listeners: exporting the
+ // transcript serializes outerHTML, which keeps the buttons but drops bound listeners.
+ // These handlers re-register when the exported page runs this script on load, so
+ // clamped prompts and collapsed panels stay expandable in the saved file.
+ document.addEventListener('click', function (e) {
+ const btn = e.target.closest('.ue-toggle');
+ if (!btn) return;
+ const echo = btn.previousElementSibling;
+ if (!echo || !echo.classList.contains('user-echo')) return;
+ const clamped = echo.classList.toggle('clamped');
+ btn.textContent = clamped ? btn.dataset.more : 'Show less';
+ });
+ document.addEventListener('click', function (e) {
+ const b = e.target.closest('.expand-btn:not(.web-collapse)');
+ if (!b) return;
+ const panel = b.closest('.panel');
+ const pre = panel && panel.querySelector('pre.collapsible');
+ if (pre) setCollapsed(pre, !pre.classList.contains('collapsed'));
+ });
+
+ window.__append = function (html) {
+ const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 60);
+ const wrap = document.createElement('div');
+ wrap.innerHTML = html;
+ while (wrap.firstChild) placeChild(wrap.firstChild);
+ highlightNew();
+ addCopyChips();
+ addReactionGhosts();
+ addCollapsers();
+ addEchoClamps();
+ if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
+ updatePill();
+ };
+ window.__clear = function () { log.innerHTML = ''; updatePill(); };
+
+ document.addEventListener('click', function (e) {
+ const link = e.target.closest('a[data-file]');
+ if (!link) return;
+ e.preventDefault();
+ window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file'));
+ });
+
+ // Interactive diff-card chips (delegated — survives transcript export, like the toggles).
+ // Clear just deletes the card from the DOM; Undo asks the host, which confirms before
+ // discarding anything. In an exported page Undo is a harmless no-op (no webview bridge).
+ document.addEventListener('click', function (e) {
+ const clear = e.target.closest('.dv-clear');
+ if (clear) {
+ const panel = clear.closest('.panel');
+ if (panel) panel.remove();
+ return;
+ }
+ const undo = e.target.closest('.dv-undo');
+ if (undo && window.chrome && window.chrome.webview)
+ window.chrome.webview.postMessage('undo-file:' + undo.getAttribute('data-file'));
+ });
+
+ // --- drag hand-off: Chromium owns drags over the transcript surface, so XAML never sees
+ // them. On dragenter we alert the host, which mounts its drop overlay over this WebView;
+ // the OS then retargets the drag (and the drop, with real file paths) to that overlay.
+ // preventDefault on dragover/drop is the safety net for a drop that lands in the instant
+ // before the overlay mounts — without it the browser would navigate to the dropped file.
+ window.addEventListener('dragenter', function (e) {
+ e.preventDefault();
+ if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('drag-enter');
+ });
+ window.addEventListener('dragover', function (e) { e.preventDefault(); });
+ window.addEventListener('drop', function (e) { e.preventDefault(); });
+
+ // Web fetch/search preview toggle: the inline chip opens the hidden detail box; the box's own
+ // Collapse button (and the chip again) closes it. Chip label and box visibility stay in sync.
+ document.addEventListener('click', function (e) {
+ const t = e.target.closest('.web-toggle, .web-collapse');
+ if (!t) return;
+ e.stopPropagation();
+ const op = t.closest('.op');
+ if (!op) return;
+ const detail = op.querySelector('.web-detail');
+ const toggle = op.querySelector('.web-toggle');
+ if (!detail || !toggle) return;
+ const open = t.classList.contains('web-collapse') ? false : detail.hasAttribute('hidden');
+ detail.toggleAttribute('hidden', !open);
+ toggle.textContent = open ? '⤡ Collapse' : '⤢ Expand';
+ });
+
+ // --- jump-to-bottom pill ---
+ const pill = document.createElement('div');
+ pill.id = 'jump-pill';
+ pill.textContent = '↓ Latest';
+ document.body.appendChild(pill);
+ pill.addEventListener('click', function () {
+ window.scrollTo({ top: document.body.scrollHeight,
+ behavior: document.documentElement.hasAttribute('data-flat') ? 'auto' : 'smooth' });
+ });
+ function updatePill() {
+ const nb = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 80);
+ pill.style.display = nb ? 'none' : 'block';
+ }
+ window.addEventListener('scroll', updatePill);
+
+ // --- in-chat find (Ctrl+F) ---
+ let findBar = null, findHits = [], findIdx = -1;
+ function ensureFindBar() {
+ if (findBar) return;
+ findBar = document.createElement('div');
+ findBar.id = 'findbar';
+ findBar.innerHTML = '' +
+ '';
+ document.body.appendChild(findBar);
+ const input = findBar.querySelector('input');
+ let deb = null;
+ input.addEventListener('input', function () {
+ clearTimeout(deb);
+ deb = setTimeout(function () { runFind(input.value); }, 150);
+ });
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); stepFind(e.shiftKey ? -1 : 1); }
+ });
+ findBar.addEventListener('click', function (e) {
+ const b = e.target.closest('button');
+ if (!b) return;
+ if (b.dataset.act === 'prev') stepFind(-1);
+ else if (b.dataset.act === 'next') stepFind(1);
+ else closeFind();
+ });
+ }
+ function setCount() {
+ if (!findBar) return;
+ findBar.querySelector('.find-count').textContent = findHits.length ? (findIdx + 1) + '/' + findHits.length : '';
+ }
+ function clearFind() {
+ findHits.forEach(function (m) {
+ const p = m.parentNode;
+ if (!p) return;
+ p.replaceChild(document.createTextNode(m.textContent), m);
+ p.normalize();
+ });
+ findHits = [];
+ findIdx = -1;
+ setCount();
+ }
+ function runFind(q) {
+ clearFind();
+ if (!q || q.length < 2) return;
+ const needle = q.toLowerCase();
+ const walker = document.createTreeWalker(log, NodeFilter.SHOW_TEXT, null);
+ const nodes = [];
+ let n;
+ while ((n = walker.nextNode())) {
+ if (n.textContent.toLowerCase().includes(needle)) nodes.push(n);
+ }
+ nodes.forEach(function (node) {
+ let text = node, idx;
+ while ((idx = text.textContent.toLowerCase().indexOf(needle)) >= 0) {
+ const hit = text.splitText(idx);
+ const rest = hit.splitText(q.length);
+ const m = document.createElement('mark');
+ m.className = 'find-hit';
+ hit.parentNode.replaceChild(m, hit);
+ m.appendChild(hit);
+ findHits.push(m);
+ text = rest;
+ }
+ });
+ if (findHits.length) { findIdx = 0; focusHit(); }
+ setCount();
+ }
+ function stepFind(dir) {
+ if (!findHits.length) return;
+ findHits[findIdx].classList.remove('find-current');
+ findIdx = (findIdx + dir + findHits.length) % findHits.length;
+ focusHit();
+ setCount();
+ }
+ function focusHit() {
+ const m = findHits[findIdx];
+ m.classList.add('find-current');
+ m.scrollIntoView({ block: 'center' });
+ }
+ function closeFind() {
+ clearFind();
+ if (findBar) findBar.style.display = 'none';
+ }
+ document.addEventListener('keydown', function (e) {
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
+ e.preventDefault();
+ ensureFindBar();
+ findBar.style.display = 'flex';
+ const input = findBar.querySelector('input');
+ input.focus();
+ input.select();
+ }
+ else if (e.key === 'Escape' && findBar && findBar.style.display !== 'none') {
+ closeFind();
+ }
+ });
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs
new file mode 100644
index 0000000..461ddd6
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs
@@ -0,0 +1,342 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // IApprovalUi — this tab's approval overlay (completes the harness's awaited TCS)
+ // ============================================================
+
+ public Task ShowApprovalAsync(ApprovalRequest request, CancellationToken ct = default)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _approvalTcs = tcs;
+
+ var reg = ct.CanBeCanceled
+ ? ct.Register(() =>
+ {
+ tcs.TrySetCanceled(ct);
+ OnUi(() => { HideApprovalOverlay(); HidePlanApprovalBar(); });
+ })
+ : default(CancellationTokenRegistration);
+
+ OnUi(() =>
+ {
+ // What the cross-tab toast will say — a specific "what's waiting" line, not the modal's
+ // question. Set for both the bottom-bar and modal paths.
+ _approvalSummary = string.IsNullOrEmpty(request.ToastSummary) ? request.Title : request.ToastSummary;
+
+ // Plan approvals render as a non-covering bottom bar so the plan card stays readable.
+ if (request.BottomBar)
+ {
+ ShowPlanApprovalBar(request, choice =>
+ {
+ HidePlanApprovalBar();
+ reg.Dispose();
+ tcs.TrySetResult(choice);
+ if (ReferenceEquals(_approvalTcs, tcs)) _approvalTcs = null;
+ });
+ return;
+ }
+
+ ApprovalTitle.Text = request.Title;
+
+ ApprovalSubtitle.Text = request.Subtitle ?? "";
+ ApprovalSubtitle.Visibility = string.IsNullOrEmpty(request.Subtitle) ? Visibility.Collapsed : Visibility.Visible;
+
+ ApprovalDetail.Text = request.Detail ?? "";
+ ApprovalDetail.Visibility = string.IsNullOrEmpty(request.Detail) ? Visibility.Collapsed : Visibility.Visible;
+
+ // Pull the shared, theme-mutated brushes from app resources so the approval diff
+ // follows the active theme (these used to be hardcoded LightSkyBlue/red/gray, which
+ // stayed blue under every theme — jarring under E-Ink). Mirrors the transcript's
+ // diff coloring: command/added -> sky, removed -> red, context -> dim.
+ var skyBrush = (SolidColorBrush)Application.Current.Resources["MandoSkyBrush"];
+ var redBrush = (SolidColorBrush)Application.Current.Resources["MandoRedBrush"];
+ var dimBrush = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+
+ var rows = new List();
+ if (request.CommandText != null)
+ {
+ rows.Add(new DiffLineVm
+ {
+ Text = $"$ {request.CommandText}",
+ Brush = skyBrush
+ });
+ }
+ if (request.DiffLines != null)
+ {
+ foreach (var line in request.DiffLines)
+ {
+ var (prefix, brush) = line.LineType switch
+ {
+ DiffLineType.Added => ("+ ", skyBrush),
+ DiffLineType.Removed => ("- ", redBrush),
+ _ => (" ", dimBrush)
+ };
+ var num = (line.LineType == DiffLineType.Added ? line.NewLineNumber : line.OldLineNumber);
+ rows.Add(new DiffLineVm
+ {
+ Text = $"{(num.HasValue ? num.Value.ToString().PadLeft(4) : " ")} {prefix}{line.Content}",
+ Brush = brush
+ });
+ }
+ if (request.DiffSummary != null)
+ rows.Add(new DiffLineVm { Text = "", Brush = dimBrush });
+ }
+ ApprovalDiffList.ItemsSource = rows;
+ ApprovalBodyScroll.Visibility = rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ if (!string.IsNullOrEmpty(request.DiffSummary))
+ {
+ ApprovalDetail.Text = request.DiffSummary;
+ ApprovalDetail.Visibility = Visibility.Visible;
+ }
+
+ ApprovalButtons.Children.Clear();
+ foreach (var option in request.Options)
+ {
+ var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
+ if (!string.IsNullOrEmpty(option.Glyph))
+ content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
+ content.Children.Add(new TextBlock { Text = option.Label });
+ var button = new Button
+ {
+ Content = content,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Left,
+ Tag = option.Label
+ };
+ button.Foreground = option.Kind switch
+ {
+ ApprovalOptionKind.Proceed => (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"],
+ ApprovalOptionKind.Destructive => (SolidColorBrush)Application.Current.Resources["MandoRedBrush"],
+ _ => (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
+ };
+ if (!string.IsNullOrEmpty(option.Description))
+ ToolTipService.SetToolTip(button, option.Description);
+ button.Click += (_, _) =>
+ {
+ var choice = (string)button.Tag;
+ HideApprovalOverlay();
+ reg.Dispose();
+ _approvalTcs?.TrySetResult(choice);
+ _approvalTcs = null;
+ };
+ ApprovalButtons.Children.Add(button);
+ }
+
+ InstructionPanel.Visibility = Visibility.Collapsed;
+ ApprovalButtons.Visibility = Visibility.Visible;
+ SetApprovalCardSize(instructionMode: false);
+ ShowApprovalOverlay();
+ });
+
+ return tcs.Task;
+ }
+
+ /// Approval mode: compact centered card. Instruction mode: full width and
+ /// half the window height, centered — room to write real instructions.
+ private void SetApprovalCardSize(bool instructionMode)
+ {
+ if (instructionMode)
+ {
+ ApprovalCard.HorizontalAlignment = HorizontalAlignment.Stretch;
+ ApprovalCard.MaxWidth = double.PositiveInfinity;
+ ApprovalCard.MaxHeight = double.PositiveInfinity;
+ ApprovalCard.Height = Math.Max(320, ChatRoot.ActualHeight * 0.5);
+ }
+ else
+ {
+ ApprovalCard.HorizontalAlignment = HorizontalAlignment.Center;
+ ApprovalCard.MaxWidth = 860;
+ ApprovalCard.MaxHeight = 640;
+ ApprovalCard.Height = double.NaN;
+ }
+ }
+
+ public Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _instructionTcs = tcs;
+
+ OnUi(() =>
+ {
+ // First line is the question; any extra lines (e.g. a validation error on
+ // re-prompt) render below it in the smaller prompt text.
+ var newline = prompt.IndexOf('\n');
+ ApprovalTitle.Text = newline < 0 ? prompt : prompt[..newline];
+ InstructionPrompt.Text = newline < 0 ? "" : prompt[(newline + 1)..].Trim();
+ InstructionPrompt.Visibility = InstructionPrompt.Text.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
+
+ ApprovalSubtitle.Visibility = Visibility.Collapsed;
+ ApprovalDetail.Visibility = Visibility.Collapsed;
+ ApprovalBodyScroll.Visibility = Visibility.Collapsed;
+ ApprovalButtons.Visibility = Visibility.Collapsed;
+
+ InstructionBox.Text = "";
+ InstructionBox.PlaceholderText = string.IsNullOrEmpty(placeholder)
+ ? "Type your answer and press Enter"
+ : placeholder;
+ InstructionCancelButton.Visibility = allowCancel ? Visibility.Visible : Visibility.Collapsed;
+ InstructionPanel.Visibility = Visibility.Visible;
+ SetApprovalCardSize(instructionMode: true);
+ ShowApprovalOverlay();
+ InstructionBox.Focus(FocusState.Programmatic);
+ });
+
+ return tcs.Task;
+ }
+
+ private void InstructionBox_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Enter)
+ {
+ // Shift+Enter inserts a newline (the box is multi-line); plain Enter submits.
+ // PreviewKeyDown is required here — with AcceptsReturn, the class handler
+ // would insert the newline before a plain KeyDown handler ever ran.
+ var shift = Microsoft.UI.Input.InputKeyboardSource
+ .GetKeyStateForCurrentThread(VirtualKey.Shift)
+ .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+ if (shift) return;
+ e.Handled = true;
+ SubmitInstruction();
+ }
+ else if (e.Key == VirtualKey.Escape && InstructionCancelButton.Visibility == Visibility.Visible)
+ {
+ e.Handled = true;
+ CancelInstruction();
+ }
+ }
+
+ private void InstructionSubmit_Click(object sender, RoutedEventArgs e) => SubmitInstruction();
+
+ private void InstructionCancel_Click(object sender, RoutedEventArgs e) => CancelInstruction();
+
+ private void SubmitInstruction()
+ {
+ var text = InstructionBox.Text;
+ HideApprovalOverlay();
+ _instructionTcs?.TrySetResult(text);
+ _instructionTcs = null;
+ }
+
+ private void CancelInstruction()
+ {
+ HideApprovalOverlay();
+ _instructionTcs?.TrySetResult(ApprovalSignals.Cancelled);
+ _instructionTcs = null;
+ }
+
+ /// An approval raised in a background tab can't steal focus, so MainWindow badges
+ /// that tab and raises the toast instead.
+ private void ShowApprovalOverlay()
+ {
+ ApprovalOverlay.Visibility = Visibility.Visible;
+ ApprovalStateChanged?.Invoke(this);
+ }
+
+ private void HideApprovalOverlay()
+ {
+ ApprovalOverlay.Visibility = Visibility.Collapsed;
+ ApprovalDiffList.ItemsSource = null;
+ ApprovalStateChanged?.Invoke(this);
+ InputBox.Focus(FocusState.Programmatic);
+ }
+
+ /// Slides the plan-approval bar up above the input. Unlike the modal it doesn't cover the
+ /// transcript (the plan stays readable), but it DOES gate input — the turn is awaiting the choice.
+ private void ShowPlanApprovalBar(ApprovalRequest request, Action onChosen)
+ {
+ // Windows 98 theme: the bar drops its rounded card look and reads as a silver
+ // dialog strip — square corners, dialog-face background. Rebuilt on every show,
+ // so live theme switches take effect on the next approval.
+ var win98 = ThemeManager.Current.Win98;
+ PlanApprovalBar.CornerRadius = new CornerRadius(win98 ? 0 : 12);
+ PlanApprovalBar.Background = (Brush)Application.Current.Resources[
+ win98 ? "MandoBackgroundBrush" : "MandoPanelBrush"];
+
+ PlanApprovalTitle.Text = request.Title;
+
+ // Command approvals ride this bar too: show the command in monospace. The buttons
+ // live in a WrapPanel — one horizontal row whenever it fits, wrapping only when the
+ // window is too narrow for the long "don't ask again" labels.
+ PlanApprovalCommand.Text = string.IsNullOrEmpty(request.CommandText) ? "" : "$ " + request.CommandText;
+ PlanApprovalCommand.Visibility = string.IsNullOrEmpty(request.CommandText)
+ ? Visibility.Collapsed : Visibility.Visible;
+
+ PlanApprovalButtons.Children.Clear();
+ foreach (var option in request.Options)
+ {
+ var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+ if (!string.IsNullOrEmpty(option.Glyph))
+ content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
+ content.Children.Add(new TextBlock { Text = option.Label });
+
+ var button = new Button { Content = content, Tag = option.Label, Padding = new Thickness(14, 6, 14, 6) };
+ if (win98) button.CornerRadius = new CornerRadius(0); // square, like every 98 control
+ if (option.Kind == ApprovalOptionKind.Proceed)
+ button.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; // primary
+ else
+ button.Foreground = option.Kind == ApprovalOptionKind.Destructive
+ ? (SolidColorBrush)Application.Current.Resources["MandoRedBrush"]
+ : (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+ if (!string.IsNullOrEmpty(option.Description))
+ ToolTipService.SetToolTip(button, option.Description);
+ button.Click += (_, _) => onChosen((string)button.Tag);
+ PlanApprovalButtons.Children.Add(button);
+ }
+
+ // Gate input while the plan is awaiting a decision.
+ InputBox.IsEnabled = false;
+ SendButton.IsEnabled = false;
+ EmojiButton.IsEnabled = false;
+
+ PlanApprovalBar.Visibility = Visibility.Visible;
+ ApprovalStateChanged?.Invoke(this);
+
+ var slide = new DoubleAnimation
+ {
+ From = 24, To = 0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(220)),
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+ };
+ Storyboard.SetTarget(slide, PlanApprovalTransform);
+ Storyboard.SetTargetProperty(slide, "Y");
+ var fade = new DoubleAnimation
+ {
+ From = 0, To = 1,
+ Duration = new Duration(TimeSpan.FromMilliseconds(180)),
+ };
+ Storyboard.SetTarget(fade, PlanApprovalBar);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+ var sb = new Storyboard();
+ sb.Children.Add(slide);
+ sb.Children.Add(fade);
+ sb.Begin();
+ }
+
+ private void HidePlanApprovalBar()
+ {
+ if (PlanApprovalBar.Visibility != Visibility.Visible) return;
+ PlanApprovalBar.Visibility = Visibility.Collapsed;
+ InputBox.IsEnabled = true;
+ SendButton.IsEnabled = true;
+ EmojiButton.IsEnabled = true;
+ ApprovalStateChanged?.Invoke(this);
+ InputBox.Focus(FocusState.Programmatic);
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
new file mode 100644
index 0000000..fa21b85
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
@@ -0,0 +1,920 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // Git status strip
+ // ============================================================
+
+ private int _branchRefreshSeq;
+ private DateTime _lastBranchRefresh = DateTime.MinValue;
+ private string? _lastGitRoot;
+ private readonly ObservableCollection _changes = new();
+
+ /// Fire-and-forget refresh of the bottom status strip AND the explorer's Changes
+ /// tab (one git call feeds both). Throttled (UpdateHeader runs on every controller state
+ /// change) except when the root changed; sequence-guarded so an older, slower git call
+ /// can never overwrite a newer result; any failure just hides the strip.
+ private async void RefreshBranchChip(bool force = false)
+ {
+ var root = _controller.ProjectRootPath;
+ if (root != _lastGitRoot) force = true; // never show the previous folder's state
+ if (!force && (DateTime.UtcNow - _lastBranchRefresh).TotalSeconds < 2) return;
+ _lastBranchRefresh = DateTime.UtcNow;
+ _lastGitRoot = root;
+
+ var seq = ++_branchRefreshSeq;
+ var info = await Task.Run(() => GitQuickStatus.TryGet(root));
+
+ if (_shutDown || seq != _branchRefreshSeq) return;
+ _lastGitInfo = info;
+ UpdateChangesList(info, root);
+ _wsTracker.CaptureBaselineIfPending(info);
+ if (info == null)
+ {
+ StatusStrip.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ BranchText.Text = info.Branch
+ + (info.Ahead > 0 ? $" ↑{info.Ahead}" : "")
+ + (info.Behind > 0 ? $" ↓{info.Behind}" : "");
+
+ // One status light: conflicts trump dirty trumps clean.
+ var (dotBrush, state) =
+ info.Conflicted ? ("MandoRedBrush", "merge conflicts")
+ : info.Dirty ? ("MandoGoldBrush", "uncommitted changes")
+ : ("MandoGreenBrush", "clean");
+ BranchDot.Fill = Application.Current.Resources[dotBrush] as Brush;
+
+ var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
+ Path.TrimEndingDirectorySeparator(info.RepoRoot),
+ Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
+ ToolTipService.SetToolTip(StatusStrip,
+ (info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
+ + " — " + state
+ + (info.Ahead > 0 || info.Behind > 0
+ ? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
+ // Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
+ + (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
+ StatusStrip.Visibility = Visibility.Visible;
+ }
+
+ /// Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).
+ private void UpdateChangesList(GitBranchInfo? info, string root)
+ {
+ if (ChangesList.ItemsSource == null) ChangesList.ItemsSource = _changes;
+
+ // Rebuilding the collection re-realizes every ListView row — a visible flash — so
+ // bail when this snapshot is identical to what's already shown (the common case:
+ // most refreshes confirm state rather than change it). Badges derive from the same
+ // data, so they can't have changed either.
+ var incoming = info?.Changes ?? (IReadOnlyList)Array.Empty();
+ if (incoming.Count == _changes.Count)
+ {
+ var identical = true;
+ for (var i = 0; i < incoming.Count; i++)
+ {
+ if (incoming[i].RelPath != _changes[i].RelPath || incoming[i].Kind != _changes[i].Kind)
+ {
+ identical = false;
+ break;
+ }
+ }
+ if (identical) return;
+ }
+
+ _changes.Clear();
+ if (info != null)
+ {
+ foreach (var c in info.Changes)
+ {
+ var relNative = c.RelPath.Replace('/', Path.DirectorySeparatorChar);
+ _changes.Add(new GitChangeItem
+ {
+ Kind = c.Kind,
+ KindBrush = BrushForKind(c.Kind),
+ KindLabel = c.Kind switch
+ {
+ "!" => "Merge conflict",
+ "U" => "Untracked (new, not yet added)",
+ "A" => "Added",
+ "D" => "Deleted",
+ "R" => "Renamed",
+ _ => "Modified",
+ },
+ Name = Path.GetFileName(c.RelPath.TrimEnd('/')),
+ Dir = Path.GetDirectoryName(relNative)?.Replace(Path.DirectorySeparatorChar, '/') ?? "",
+ FullPath = Path.Combine(root, relNative),
+ RelPath = c.RelPath,
+ TagTooltip = $"Tag in prompt — inserts @{c.RelPath}",
+ });
+ }
+ }
+
+ ChangesTabButton.Content = _changes.Count > 0 ? $"Changes ({_changes.Count})" : "Changes";
+ ChangesEmptyText.Visibility = _changesTabActive && _changes.Count == 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ CommitButton.IsEnabled = _changes.Count > 0;
+
+ RebuildDirtySets(info);
+ RefreshExplorerDirtyFlags();
+ }
+
+ // --- dirty badges on the file tree ---
+ // A changed file gets a gold dot; every ancestor folder gets one too, so a collapsed
+ // folder still signals "something inside changed" (VS Code's badge behavior).
+
+ private readonly HashSet _gitDirtyFiles = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _gitDirtyDirs = new(StringComparer.OrdinalIgnoreCase);
+
+ private void RebuildDirtySets(GitBranchInfo? info)
+ {
+ _gitDirtyFiles.Clear();
+ _gitDirtyDirs.Clear();
+ if (info == null) return;
+ foreach (var c in info.Changes)
+ {
+ var rel = c.RelPath.TrimEnd('/');
+ // Untracked directories arrive as one "dir/" entry — that's a dir badge, not a file.
+ if (c.RelPath.EndsWith('/')) _gitDirtyDirs.Add(rel);
+ else _gitDirtyFiles.Add(rel);
+ for (var slash = rel.LastIndexOf('/'); slash > 0; slash = rel.LastIndexOf('/'))
+ {
+ rel = rel[..slash];
+ _gitDirtyDirs.Add(rel);
+ }
+ }
+ }
+
+ /// Re-flags every REALIZED tree node in place (expansion state survives).
+ /// Nodes created later pick their flag up at creation in LoadChildNodes.
+ private void RefreshExplorerDirtyFlags()
+ {
+ Walk(ExplorerTree.RootNodes);
+
+ void Walk(IList nodes)
+ {
+ foreach (var node in nodes)
+ {
+ if (node.Content is ExplorerItem item) item.Dirty = IsItemDirty(item);
+ if (node.Children.Count > 0) Walk(node.Children);
+ }
+ }
+ }
+
+ private bool IsItemDirty(ExplorerItem item) =>
+ item.IsDirectory ? _gitDirtyDirs.Contains(item.RelPath) : _gitDirtyFiles.Contains(item.RelPath);
+
+ private static Brush? BrushForKind(string kind) =>
+ Application.Current.Resources[kind switch
+ {
+ "!" or "D" => "MandoRedBrush",
+ "A" or "U" => "MandoGreenBrush",
+ "R" => "MandoSkyBrush",
+ _ => "MandoGoldBrush",
+ }] as Brush;
+
+ private void UpdatePlanProgress(int done, int total, bool active)
+ {
+ PlanProgressPanel.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
+ if (total > 0)
+ {
+ PlanProgressBar.Value = done * 100.0 / total;
+ PlanProgressText.Text = $"Plan: step {Math.Min(done + 1, total)} of {total}";
+ }
+ }
+
+ ///
+ /// Populates the model dropdown each time it opens. The flyout appears immediately showing a
+ /// loading spinner; this awaits the model list off the UI thread and swaps in the rows (or an
+ /// inline error) when it returns. Tab-local — picking a model repins THIS agent only.
+ ///
+ private async void ModelFlyout_Opening(object? sender, object e)
+ {
+ ModelLoadingPanel.Visibility = Visibility.Visible;
+ ModelErrorText.Visibility = Visibility.Collapsed;
+ ModelList.Visibility = Visibility.Collapsed;
+
+ var result = await _controller.LoadAvailableModelsAsync();
+
+ if (!result.Ok)
+ {
+ ModelErrorText.Text = result.Error;
+ ModelLoadingPanel.Visibility = Visibility.Collapsed;
+ ModelErrorText.Visibility = Visibility.Visible;
+ return;
+ }
+
+ var sky = (Brush)Application.Current.Resources["MandoSkyBrush"];
+ var dim = (Brush)Application.Current.Resources["MandoDimBrush"];
+ var badgeBg = new SolidColorBrush(Windows.UI.Color.FromArgb(0x22, 0x80, 0x80, 0x80));
+ var current = _controller.ModelName;
+
+ var items = result.Models.Select(m =>
+ {
+ var cloud = MandoCodeConfig.IsCloudModel(m);
+ return new ModelItem(m, cloud ? "cloud" : "local", cloud ? sky : dim, badgeBg);
+ }).ToList();
+
+ ModelList.ItemsSource = items;
+ ModelList.SelectedItem = items.FirstOrDefault(
+ i => string.Equals(i.Name, current, StringComparison.OrdinalIgnoreCase));
+
+ ModelLoadingPanel.Visibility = Visibility.Collapsed;
+ ModelList.Visibility = Visibility.Visible;
+ }
+
+ private async void ModelList_ItemClick(object sender, ItemClickEventArgs e)
+ {
+ ModelFlyout.Hide();
+ if (e.ClickedItem is not ModelItem item) return;
+ if (string.Equals(item.Name, _controller.ModelName, StringComparison.OrdinalIgnoreCase)) return;
+
+ await Task.Run(() => _controller.SelectModelAsync(item.Name));
+ UpdateHeader();
+ }
+
+ private async void OpenFolderButton_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(_owner));
+
+ var folder = await picker.PickSingleFolderAsync();
+ if (folder == null) return;
+
+ _transcript.Append(_html.Info($"Project root changed to: {folder.Path}"));
+ _transcript.Append(_html.Dim("Rebuilding the AI session for the new project…"));
+
+ // Retargets THIS tab only — its own ProjectRootAccessor, file cache, and kernel.
+ // Other agents keep working in their own folders.
+ var session = Session;
+ await Task.Run(async () =>
+ {
+ await session.ChangeProjectRootAsync(folder.Path);
+ _transcript.Append(_html.Success("✓ Ready."));
+ });
+ UpdateHeader();
+ if (_explorerOpen) BuildExplorerRoot(); // the open tree must follow the new root
+ }
+
+ // ============================================================
+ // File explorer panel
+ // ============================================================
+
+ private bool _explorerOpen;
+ private string? _explorerRoot; // root the tree was last built for
+
+ private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen);
+ private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false);
+
+ private void ExplorerRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ BuildExplorerRoot();
+ RefreshBranchChip(force: true); // the Changes tab re-reads too
+ }
+
+ // --- Files / Changes tabs ---
+
+ private bool _changesTabActive;
+
+ private void FilesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: false);
+ private void ChangesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: true);
+
+ private void SetExplorerTab(bool changes)
+ {
+ _changesTabActive = changes;
+ ExplorerTree.Visibility = changes ? Visibility.Collapsed : Visibility.Visible;
+ ChangesList.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+ ChangesEmptyText.Visibility = changes && _changes.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ChangesFooter.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+ CommitButton.IsEnabled = _changes.Count > 0;
+ FilesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.Normal : Microsoft.UI.Text.FontWeights.SemiBold;
+ ChangesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
+ FilesTabButton.Opacity = changes ? 0.55 : 1;
+ ChangesTabButton.Opacity = changes ? 1 : 0.55;
+ }
+
+ private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_explorerOpen) SizeExplorer();
+ }
+
+ private void SizeExplorer()
+ {
+ // Default ~20% of the window, clamped so the tree stays usable on small windows and
+ // doesn't waste half a 4K monitor on the other end. Once the user has dragged the
+ // splitter, their width wins (re-clamped so a shrunken window can't strand the panel).
+ var w = ChatRoot.ActualWidth;
+ if (w <= 0) return;
+ var target = _explorerUserWidth ?? Math.Clamp(w * 0.20, 220, 460);
+ ExplorerPanel.Width = Math.Clamp(target, MinExplorerWidth, MaxExplorerWidth());
+ }
+
+ private const double MinExplorerWidth = 180;
+ private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, ChatRoot.ActualWidth * 0.6);
+
+ // --- splitter drag (same pointer-capture pattern as MainWindow's terminal splitter) ---
+
+ private double? _explorerUserWidth; // set on first drag; SizeExplorer defers to it
+ private bool _draggingExplorer;
+ private double _explorerDragStartWidth;
+ private double _explorerDragStartX;
+
+ private void ExplorerSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ _draggingExplorer = true;
+ _explorerDragStartWidth = ExplorerPanel.ActualWidth;
+ _explorerDragStartX = e.GetCurrentPoint(ChatRoot).Position.X; // stable frame while the grip moves
+ ((UIElement)sender).CapturePointer(e.Pointer);
+ }
+
+ private void ExplorerSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingExplorer) return;
+ // Dragging left grows the panel; right shrinks it.
+ var delta = e.GetCurrentPoint(ChatRoot).Position.X - _explorerDragStartX;
+ var next = Math.Clamp(_explorerDragStartWidth - delta, MinExplorerWidth, MaxExplorerWidth());
+ ExplorerPanel.Width = next;
+ _explorerUserWidth = next;
+ }
+
+ private void ExplorerSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingExplorer) return;
+ _draggingExplorer = false;
+ ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+ }
+
+ private void ToggleExplorer(bool open)
+ {
+ if (open == _explorerOpen) return;
+ _explorerOpen = open;
+
+ // Docked, not overlaid: the panel sits in the transcript row's second column, so
+ // showing it RESIZES the transcript (text stays fully readable) and collapsing it
+ // gives the width back. No slide animation — animating a WebView2's width forces
+ // continuous relayout of the browser surface, and instant dock/undock is how
+ // solution-explorer-style panels behave anyway.
+ if (open)
+ {
+ SizeExplorer();
+ // (Re)build on open when the tab's root changed since the tree was built — the
+ // panel keeps its expansion state across close/open within the same root.
+ if (_explorerRoot != _controller.ProjectRootPath) BuildExplorerRoot();
+ ExplorerPanel.Visibility = Visibility.Visible;
+ ExplorerSplitter.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ ExplorerPanel.Visibility = Visibility.Collapsed;
+ ExplorerSplitter.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private void BuildExplorerRoot()
+ {
+ _explorerRoot = _controller.ProjectRootPath;
+ ExplorerRootText.Text = Path.GetFileName(Path.TrimEndingDirectorySeparator(_explorerRoot));
+ ToolTipService.SetToolTip(ExplorerRootText, _explorerRoot);
+ ExplorerTree.RootNodes.Clear();
+ foreach (var node in LoadChildNodes(_explorerRoot)) ExplorerTree.RootNodes.Add(node);
+ StartExplorerWatcher(_explorerRoot);
+ }
+
+ // --- filesystem watcher: the tree follows external creates/deletes/renames on its own ---
+ // Efficiency comes from three choices: (1) only NAME notifications — content writes don't
+ // change tree shape; (2) events debounce into one flush, so a build touching 500 files
+ // costs one pass; (3) a flush re-syncs only REALIZED directory nodes — churn under a
+ // never-expanded folder (node_modules, bin/obj) is a hash lookup and a skip, because
+ // lazy loading will read the truth from disk whenever it's finally expanded.
+
+ private FileSystemWatcher? _fsWatcher;
+ private readonly object _fsLock = new();
+ private readonly HashSet _pendingFsDirs = new(StringComparer.OrdinalIgnoreCase);
+ private bool _fsFlushQueued;
+ private bool _fsSyncAll; // watcher buffer overflowed — re-sync every realized dir
+
+ private void StartExplorerWatcher(string root)
+ {
+ StopExplorerWatcher();
+ try
+ {
+ _fsWatcher = new FileSystemWatcher(root)
+ {
+ IncludeSubdirectories = true,
+ // LastWrite so EDITS refresh git state (M rows, badges, dirty dot) — name
+ // events alone only cover tree shape. Content writes are routed git-only
+ // below: they can't change the tree, so they never trigger tree syncs.
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
+ InternalBufferSize = 64 * 1024, // max — fewer overflows during big builds
+ };
+ _fsWatcher.Created += (_, e) => QueueFsEvent(e.FullPath);
+ _fsWatcher.Deleted += (_, e) => QueueFsEvent(e.FullPath);
+ _fsWatcher.Renamed += (_, e) => { QueueFsEvent(e.OldFullPath); QueueFsEvent(e.FullPath); };
+ _fsWatcher.Changed += (_, e) => QueueFsEvent(e.FullPath, treeRelevant: false);
+ _fsWatcher.Error += (_, _) => { lock (_fsLock) { _fsSyncAll = true; } QueueFsEvent(root); };
+ _fsWatcher.EnableRaisingEvents = true;
+ }
+ catch
+ {
+ _fsWatcher = null; // best-effort — the refresh button still exists
+ }
+ }
+
+ private void StopExplorerWatcher()
+ {
+ try { _fsWatcher?.Dispose(); } catch { }
+ _fsWatcher = null;
+ }
+
+ /// Threadpool-side: coalesce this event's parent directory into the pending set
+ /// and arm one debounced flush. .git churn and content-only writes skip the tree but
+ /// still refresh git state — that's how external edits, branch switches, and commits
+ /// show up without a manual refresh.
+ private void QueueFsEvent(string fullPath, bool treeRelevant = true)
+ {
+ bool arm;
+ lock (_fsLock)
+ {
+ var rel = ToRelOrNull(fullPath)?.Replace('\\', '/');
+ if (rel == null) return;
+ var isGit = rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase);
+
+ // Our OWN git calls write .git/index (+ transient *.lock files) — reacting to
+ // those would refresh forever: refresh → git status → index event → refresh…
+ // Ignore them; real external actions (checkout, commit) also touch HEAD/refs,
+ // which still get through and trigger the refresh we want.
+ if (isGit && (rel.EndsWith("/index", StringComparison.OrdinalIgnoreCase)
+ || rel.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)))
+ return;
+
+ if (!isGit && treeRelevant)
+ _pendingFsDirs.Add(Path.GetDirectoryName(fullPath) ?? "");
+
+ // Workspace notes: remember WHICH files were touched while the agent was idle.
+ // Status-snapshot diffing alone misses content edits to files that were ALREADY
+ // dirty/untracked (their status entry doesn't change) — this set fills that gap.
+ // Idle-gated so the agent's own writes never count as external.
+ if (!isGit && !_controller.IsProcessing)
+ _wsTracker.RecordTouch(rel);
+
+ arm = !_fsFlushQueued;
+ _fsFlushQueued = true;
+ }
+ if (arm) _ = FlushFsEventsAsync();
+
+ string? ToRelOrNull(string p)
+ {
+ var root = _explorerRoot;
+ if (root == null) return null;
+ var prefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
+ return p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? p[prefix.Length..] : null;
+ }
+ }
+
+ private async Task FlushFsEventsAsync()
+ {
+ await Task.Delay(800); // coalesce the burst
+ List dirs;
+ bool syncAll;
+ lock (_fsLock)
+ {
+ syncAll = _fsSyncAll;
+ _fsSyncAll = false;
+ dirs = _pendingFsDirs.ToList();
+ _pendingFsDirs.Clear();
+ _fsFlushQueued = false;
+ }
+ OnUi(() =>
+ {
+ if (_shutDown) return;
+ if (syncAll) SyncAllRealizedDirs();
+ else foreach (var dir in dirs) SyncRealizedDir(dir);
+ RefreshBranchChip(force: true); // badges, Changes tab, and status strip follow
+ });
+ }
+
+ /// Re-syncs one directory's children IF that directory is realized in the tree;
+ /// unexpanded directories are skipped (lazy load reads fresh from disk anyway).
+ private void SyncRealizedDir(string dir)
+ {
+ var list = FindRealizedChildList(dir);
+ if (list != null) SyncDirectoryNode(list, dir);
+ }
+
+ private void SyncAllRealizedDirs()
+ {
+ var root = _explorerRoot;
+ if (root == null) return;
+ SyncDirectoryNode(ExplorerTree.RootNodes, root);
+ Walk(ExplorerTree.RootNodes);
+
+ void Walk(IList nodes)
+ {
+ foreach (var n in nodes)
+ {
+ if (n is { HasUnrealizedChildren: false, Content: ExplorerItem { IsDirectory: true } item })
+ {
+ SyncDirectoryNode(n.Children, item.FullPath);
+ Walk(n.Children);
+ }
+ }
+ }
+ }
+
+ private IList? FindRealizedChildList(string dir)
+ {
+ var root = _explorerRoot;
+ if (root == null) return null;
+ if (PathsEqual(dir, root)) return ExplorerTree.RootNodes;
+ return Find(ExplorerTree.RootNodes);
+
+ IList? Find(IList nodes)
+ {
+ foreach (var n in nodes)
+ {
+ if (n.Content is ExplorerItem { IsDirectory: true } item && PathsEqual(item.FullPath, dir))
+ return n.HasUnrealizedChildren ? null : n.Children;
+ if (n.Children.Count > 0)
+ {
+ var found = Find(n.Children);
+ if (found != null) return found;
+ }
+ }
+ return null;
+ }
+
+ static bool PathsEqual(string a, string b) => string.Equals(
+ Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Minimal diff of a realized directory node against disk: remove rows whose
+ /// path vanished, insert new rows at their sorted position. Never rebuilds surviving
+ /// nodes, so expansion state below them is preserved.
+ private void SyncDirectoryNode(IList children, string dir)
+ {
+ var root = _explorerRoot ?? _controller.ProjectRootPath;
+ string[] dirs, files;
+ try
+ {
+ dirs = Directory.GetDirectories(dir);
+ files = Directory.GetFiles(dir);
+ }
+ catch (Exception) { return; }
+ Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
+ Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+
+ var desired = new List<(string Path, bool IsDir)>(dirs.Length + files.Length);
+ foreach (var d in dirs) desired.Add((d, true));
+ foreach (var f in files) desired.Add((f, false));
+ var desiredSet = new HashSet(desired.Select(x => x.Path), StringComparer.OrdinalIgnoreCase);
+
+ for (var i = children.Count - 1; i >= 0; i--)
+ if (children[i].Content is ExplorerItem it && !desiredSet.Contains(it.FullPath))
+ children.RemoveAt(i);
+
+ var existing = new HashSet(
+ children.Select(n => (n.Content as ExplorerItem)?.FullPath ?? ""),
+ StringComparer.OrdinalIgnoreCase);
+
+ for (var idx = 0; idx < desired.Count; idx++)
+ {
+ var (path, isDir) = desired[idx];
+ if (existing.Contains(path)) continue;
+ var item = isDir ? ExplorerItem.ForFolder(path, root) : ExplorerItem.ForFile(path, root);
+ item.Dirty = IsItemDirty(item);
+ var node = new TreeViewNode { Content = item };
+ if (isDir) node.HasUnrealizedChildren = true;
+ children.Insert(Math.Min(idx, children.Count), node);
+ }
+ }
+
+ /// One directory level, folders first then files, both alphabetical. Unreadable
+ /// or vanished directories render as empty rather than throwing.
+ private List LoadChildNodes(string dir)
+ {
+ var root = _explorerRoot ?? _controller.ProjectRootPath;
+ var nodes = new List();
+ string[] dirs, files;
+ try
+ {
+ dirs = Directory.GetDirectories(dir);
+ files = Directory.GetFiles(dir);
+ }
+ catch (Exception) { return nodes; }
+ Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
+ Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+ foreach (var d in dirs)
+ {
+ var item = ExplorerItem.ForFolder(d, root);
+ item.Dirty = IsItemDirty(item);
+ nodes.Add(new TreeViewNode { Content = item, HasUnrealizedChildren = true });
+ }
+ foreach (var f in files)
+ {
+ var item = ExplorerItem.ForFile(f, root);
+ item.Dirty = IsItemDirty(item);
+ nodes.Add(new TreeViewNode { Content = item });
+ }
+ return nodes;
+ }
+
+ /// The row's @ button — shared by the file tree (TreeViewNode rows) and the
+ /// Changes list (GitChangeItem rows): tags the file/folder in the prompt, identical
+ /// result to dragging the row onto the input box.
+ private void ExplorerTag_Click(object sender, RoutedEventArgs e)
+ {
+ var ctx = (sender as FrameworkElement)?.DataContext;
+ var path = ctx switch
+ {
+ TreeViewNode { Content: ExplorerItem item } => item.FullPath,
+ GitChangeItem change => change.FullPath,
+ _ => null,
+ };
+ if (path != null) InsertFileTokens(new[] { path });
+ }
+
+ private void ChangesList_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
+ {
+ var paths = e.Items.OfType().Select(c => c.FullPath).ToList();
+ if (paths.Count == 0) { e.Cancel = true; return; }
+ e.Data.SetText(string.Join("\n", paths));
+ e.Data.RequestedOperation = DataPackageOperation.Copy;
+ }
+
+ /// The row's ± button: show this file's diff as a transcript DiffCard. An
+ /// explicit button (not row click) so selecting or starting a drag never spawns a card,
+ /// and no click-vs-double-click disambiguation delay is needed.
+ private async void ChangesDiff_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is not GitChangeItem item || _shutDown) return;
+
+ var root = _controller.ProjectRootPath;
+ var diff = await Task.Run(() => GitQuickStatus.TryGetDiff(root, item.RelPath, untracked: item.Kind == "U"));
+ if (_shutDown) return;
+
+ if (diff == null)
+ _transcript.Append(_html.Warn($"Couldn't get a diff for {item.RelPath}"));
+ else if (diff.Lines.Count == 0)
+ _transcript.Append(_html.Dim($"{item.RelPath}: {diff.Summary}"));
+ else
+ _transcript.Append(_html.DiffCard(item.RelPath, diff.Lines, diff.Summary, interactive: true));
+ }
+
+ /// Pre-fills the prompt with a commit request — never sends, never commits.
+ /// Caret-aware insert, so tagging files first then clicking Commit… composes naturally
+ /// ("@a.cs @b.cs Commit the current changes…"). The user can edit, then sends; the
+ /// bottom-bar approval gates the actual git command.
+ private void Commit_Click(object sender, RoutedEventArgs e) =>
+ InsertAtCaret("Commit the current changes with an appropriate message");
+
+ private void ChangeUndo_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is GitChangeItem item)
+ UndoFileFromCard(item.RelPath);
+ }
+
+ /// Fire-and-forget bridge for non-async call sites (web message handler, row
+ /// button). async void is safe here: ConfirmAndUndoAsync catches nothing fatal — git
+ /// failure is reported to the transcript, not thrown.
+ private async void UndoFileFromCard(string relPath) => await ConfirmAndUndoAsync(relPath);
+
+ /// The one destructive action in the app, so it always confirms first —
+ /// whether it came from a Changes row or a diff card's Undo chip.
+ private async Task ConfirmAndUndoAsync(string relPath)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "Discard changes?",
+ Content = $"{relPath} will be restored to its state at the last commit. This can't be undone.",
+ PrimaryButtonText = "Discard changes",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ var root = _controller.ProjectRootPath;
+ var ok = await Task.Run(() => GitQuickStatus.TryUndoChanges(root, relPath));
+ if (_shutDown) return;
+ _transcript.Append(ok
+ ? _html.Success($"Restored {relPath} to its state at the last commit.")
+ : _html.Warn($"Couldn't restore {relPath} — is it still tracked by git?"));
+ if (ok)
+ {
+ // Tell the model explicitly — discarding its work is feedback, not just a file
+ // event — and re-baseline so the generic delta doesn't report it a second time.
+ _controller.NoteWorkspaceEvent(
+ $"The user DISCARDED all uncommitted changes to {relPath} (restored to the last commit). " +
+ "If you changed that file earlier, those changes are gone by the user's choice — don't re-apply them unless asked.");
+ _wsTracker.MarkCapturePending();
+ }
+ RefreshBranchChip(force: true);
+ }
+
+ private void ChangesList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ if ((e.OriginalSource as FrameworkElement)?.DataContext is not GitChangeItem item) return;
+ if (!File.Exists(item.FullPath)) return; // deleted entries have nothing to open
+ if (ShellOpen.Try(item.FullPath) is { } ex)
+ _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+ }
+
+ private void ExplorerTag_PointerEntered(object sender, PointerRoutedEventArgs e)
+ => ((UIElement)sender).Opacity = 1;
+
+ private void ExplorerTag_PointerExited(object sender, PointerRoutedEventArgs e)
+ => ((UIElement)sender).Opacity = 0.45;
+
+ private void ExplorerTree_Expanding(TreeView sender, TreeViewExpandingEventArgs args)
+ {
+ if (!args.Node.HasUnrealizedChildren) return;
+ args.Node.HasUnrealizedChildren = false;
+ if (args.Node.Content is not ExplorerItem item || !item.IsDirectory) return;
+ foreach (var child in LoadChildNodes(item.FullPath)) args.Node.Children.Add(child);
+ }
+
+ private void ExplorerTree_ItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
+ {
+ // Single click: folders toggle, files only select. Opening is double-click territory
+ // (ExplorerTree_DoubleTapped) — a stray single click must never launch an app.
+ if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: true } } node)
+ node.IsExpanded = !node.IsExpanded;
+ }
+
+ private void ExplorerTree_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ // The template's elements inherit the row's TreeViewNode as DataContext.
+ if ((e.OriginalSource as FrameworkElement)?.DataContext is not TreeViewNode node ||
+ node.Content is not ExplorerItem { IsDirectory: false } item)
+ return;
+ if (ShellOpen.Try(item.FullPath) is { } ex)
+ _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+ }
+
+ // ============================================================
+ // Drag & drop @-references
+ // ============================================================
+
+ /// Dragging explorer rows carries their full paths as text — the input box's
+ /// Drop handler recognizes existing paths and converts them to @tokens.
+ private void ExplorerTree_DragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
+ {
+ var paths = args.Items.OfType()
+ .Select(n => n.Content).OfType()
+ .Select(i => i.FullPath).ToList();
+ if (paths.Count == 0) { args.Cancel = true; return; }
+ args.Data.SetText(string.Join("\n", paths));
+ args.Data.RequestedOperation = DataPackageOperation.Copy;
+ }
+
+ private void InputBox_DragOver(object sender, DragEventArgs e)
+ {
+ if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+ e.DataView.Contains(StandardDataFormats.Text))
+ {
+ e.AcceptedOperation = DataPackageOperation.Copy;
+ e.Handled = true;
+ }
+ }
+
+ // --- drop-to-tag overlay choreography ---
+ // Show when a drag enters the tab: over XAML chrome that's ChatRoot's DragEnter; over the
+ // WebView it's the transcript script's 'drag-enter' message (Chromium owns drags there).
+ // Hide when the drag leaves the overlay/tab or when any drop completes. Moving between
+ // those regions can flicker the overlay off/on for a frame — harmless.
+
+ private void ShowDropOverlay() => DropOverlay.Visibility = Visibility.Visible;
+ private void HideDropOverlay() => DropOverlay.Visibility = Visibility.Collapsed;
+
+ private void ChatRoot_DragEnter(object sender, DragEventArgs e)
+ {
+ if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+ e.DataView.Contains(StandardDataFormats.Text))
+ ShowDropOverlay();
+ }
+
+ private void ChatRoot_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
+ private void DropOverlay_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
+
+ private void DropOverlay_DragOver(object sender, DragEventArgs e)
+ {
+ if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+ e.DataView.Contains(StandardDataFormats.Text))
+ {
+ e.AcceptedOperation = DataPackageOperation.Copy;
+ e.Handled = true;
+ }
+ }
+
+ private async void DropOverlay_Drop(object sender, DragEventArgs e)
+ {
+ HideDropOverlay();
+ await HandleDropAsync(e);
+ }
+
+ private async void InputBox_Drop(object sender, DragEventArgs e)
+ {
+ HideDropOverlay();
+ await HandleDropAsync(e);
+ }
+
+ /// Shared drop handling for the input box and the drop-to-tag overlay: paths
+ /// become @tokens, ordinary text inserts as text.
+ private async Task HandleDropAsync(DragEventArgs e)
+ {
+ e.Handled = true;
+ var deferral = e.GetDeferral();
+ try
+ {
+ if (e.DataView.Contains(StandardDataFormats.StorageItems))
+ {
+ // Shell drop (Windows Explorer): real files/folders with paths.
+ var items = await e.DataView.GetStorageItemsAsync();
+ InsertFileTokens(items.Select(i => i.Path).Where(p => !string.IsNullOrEmpty(p)));
+ }
+ else if (e.DataView.Contains(StandardDataFormats.Text))
+ {
+ // Text drop: explorer-tree rows arrive as newline-joined full paths. If every
+ // line is an existing path, tokenize; otherwise it's ordinary dragged text.
+ var text = await e.DataView.GetTextAsync();
+ var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (lines.Length > 0 && lines.All(l => File.Exists(l) || Directory.Exists(l)))
+ InsertFileTokens(lines);
+ else
+ InsertAtCaret(text);
+ }
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Warn($"Couldn't read the dropped item: {ex.Message}"));
+ }
+ finally
+ {
+ deferral.Complete();
+ }
+ }
+
+ /// Converts full paths into the same @tokens the autocomplete inserts: project-root
+ /// relative, forward slashes, trailing '/' for folders. Items outside this tab's project
+ /// root can't be resolved by the @ pipeline, so they're skipped with a warning.
+ private void InsertFileTokens(IEnumerable fullPaths)
+ {
+ var root = _controller.ProjectRootPath;
+ var rootPrefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
+ var tokens = new List();
+ var outside = new List();
+
+ foreach (var raw in fullPaths)
+ {
+ string full;
+ try { full = Path.GetFullPath(raw); }
+ catch { continue; }
+ if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ outside.Add(full);
+ continue;
+ }
+ var rel = Path.GetRelativePath(root, full).Replace('\\', '/');
+ tokens.Add("@" + rel + (Directory.Exists(full) ? "/" : ""));
+ }
+
+ if (tokens.Count > 0)
+ InsertAtCaret(string.Join(" ", tokens) + " ");
+ if (outside.Count > 0)
+ _transcript.Append(_html.Warn(
+ $"Skipped {outside.Count} dropped item{(outside.Count == 1 ? "" : "s")} outside this tab's project folder — @ references only work under {root}"));
+ }
+
+ /// Inserts at the caret with token-safe spacing: a separating space is added when
+ /// the caret touches non-whitespace, so a dropped @token never glues onto existing text.
+ private void InsertAtCaret(string insert)
+ {
+ var text = InputBox.Text;
+ var caret = Math.Clamp(InputBox.SelectionStart, 0, text.Length);
+ if (caret > 0 && !char.IsWhiteSpace(text[caret - 1])) insert = " " + insert;
+ InputBox.Text = text[..caret] + insert + text[caret..];
+ InputBox.SelectionStart = caret + insert.Length;
+ InputBox.Focus(FocusState.Programmatic);
+ }
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs
new file mode 100644
index 0000000..c5d70d2
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs
@@ -0,0 +1,88 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // Header / busy / plan progress
+ // ============================================================
+
+ public void UpdateHeader()
+ {
+ ModelText.Text = _controller.ModelName;
+ ProjectRootText.Text = _controller.ProjectRootPath;
+ ConnectionDot.Fill = new SolidColorBrush(
+ _controller.ModelError ? Colors.Orange
+ : _controller.IsConnected ? Colors.LimeGreen
+ : Colors.Gray);
+
+ var tracker = Session.Tokens;
+ TokenText.Text = tracker.TotalSessionTokens > 0
+ ? $"{MandoCode.Services.TokenTrackingService.FormatTokenCount(tracker.TotalSessionTokens)} tokens"
+ : "";
+
+ var processing = _controller.IsProcessing;
+ SendIcon.Glyph = processing ? "" : ""; // stop vs send
+ SendLabel.Text = processing ? "Stop" : "Send";
+ ModelButton.IsEnabled = !processing; // no model switch mid-turn
+
+ RefreshBranchChip();
+
+ HeaderChanged?.Invoke(this);
+ }
+
+ private void UpdateBusy(bool busy, string? activity)
+ {
+ BusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ BusyRing.IsActive = busy;
+ if (busy) BusyText.Text = string.IsNullOrWhiteSpace(activity) ? "Working..." : activity;
+ else
+ {
+ // Turn just ended: refresh git state and snapshot it as the baseline for
+ // detecting OUTSIDE-the-conversation changes before the next send.
+ _wsTracker.MarkCapturePending();
+ RefreshBranchChip(force: true);
+
+ // Persist the model's full memory as of this turn (tier-3 full fidelity).
+ // Off-thread: serialization of a long history shouldn't touch UI latency.
+ var key = Session.PersistKey;
+ var ai = Session.Ai;
+ _ = Task.Run(() => SessionHistoryStore.Save(key, ai.ExportHistoryJson()));
+ }
+ }
+
+ // ============================================================
+ // Workspace-change notes for the model
+ // ============================================================
+ // The model only knows what happened inside the conversation. Anything else — the undo
+ // button discarding its edits, files changed in another editor, external branch switches
+ // — is invisible to it and leaves its picture of the working tree stale. The decision
+ // logic lives in WorkspaceDeltaTracker (pure, unit-testable); this class only feeds it:
+ // turn end → MarkCapturePending, git refresh → CaptureBaselineIfPending, watcher touch →
+ // RecordTouch, send → EmitDelta. Notes queue on the controller (same pattern as reactions).
+
+ private GitBranchInfo? _lastGitInfo;
+ private readonly WorkspaceDeltaTracker _wsTracker = new();
+
+ /// Called at send time: queues notes for whatever changed outside the
+ /// conversation since the last turn ended, then re-baselines.
+ private void EmitWorkspaceDelta()
+ {
+ foreach (var note in _wsTracker.EmitDelta(_lastGitInfo))
+ _controller.NoteWorkspaceEvent(note);
+ }
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs
new file mode 100644
index 0000000..1da5943
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs
@@ -0,0 +1,304 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // Input handling
+ // ============================================================
+
+ private void SendButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_controller.IsProcessing)
+ {
+ _controller.CancelActiveRequest();
+ return;
+ }
+ SubmitCurrentInput();
+ }
+
+ private void SubmitCurrentInput()
+ {
+ var text = InputBox.Text;
+ if (string.IsNullOrWhiteSpace(text) || _controller.IsProcessing) return;
+
+ EmitWorkspaceDelta(); // queue outside-the-conversation changes before this send
+ InputBox.Text = "";
+ HideSuggestions();
+ UpdateHeader();
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _controller.SubmitAsync(text);
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Error($"Unexpected error: {ex.Message}"));
+ }
+ });
+ }
+
+ // PreviewKeyDown, NOT KeyDown: the TextBox's own class handler runs before instance
+ // KeyDown handlers, so with AcceptsReturn=true an Enter had already inserted a newline
+ // — which made TextChanged hide the suggestions popup, and the handler then fell
+ // through to submit. Preview (tunneling) fires first, so Handled=true genuinely
+ // suppresses the newline and Enter-to-accept behaves exactly like a mouse click.
+ private void InputBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Enter)
+ {
+ var shift = Microsoft.UI.Input.InputKeyboardSource
+ .GetKeyStateForCurrentThread(VirtualKey.Shift)
+ .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+ if (!shift)
+ {
+ e.Handled = true;
+
+ // If suggestions are open, Enter accepts (falling back to the first row —
+ // never submit the half-typed token as a message).
+ if (SuggestionsPanel.Visibility == Visibility.Visible)
+ {
+ var pick = SuggestionsList.SelectedItem as CommandSuggestion ?? _suggestions.FirstOrDefault();
+ if (pick != null)
+ {
+ AcceptSuggestion(pick);
+ return;
+ }
+ }
+ SubmitCurrentInput();
+ }
+ }
+ else if (e.Key == VirtualKey.Tab && SuggestionsPanel.Visibility == Visibility.Visible)
+ {
+ var pick = (SuggestionsList.SelectedItem ?? _suggestions.FirstOrDefault()) as CommandSuggestion;
+ if (pick != null)
+ {
+ e.Handled = true;
+ AcceptSuggestion(pick);
+ }
+ }
+ else if (e.Key == VirtualKey.Down && SuggestionsPanel.Visibility == Visibility.Visible)
+ {
+ e.Handled = true;
+ SuggestionsList.SelectedIndex = Math.Min(SuggestionsList.SelectedIndex + 1, _suggestions.Count - 1);
+ SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+ }
+ else if (e.Key == VirtualKey.Up && SuggestionsPanel.Visibility == Visibility.Visible)
+ {
+ e.Handled = true;
+ SuggestionsList.SelectedIndex = Math.Max(SuggestionsList.SelectedIndex - 1, 0);
+ SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+ }
+ else if (e.Key == VirtualKey.Escape)
+ {
+ if (SuggestionsPanel.Visibility == Visibility.Visible) HideSuggestions();
+ else _controller.CancelActiveRequest();
+ }
+ }
+
+ private void InputBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSuggestions();
+
+ private void UpdateSuggestions()
+ {
+ var text = InputBox.Text;
+ var caret = InputBox.SelectionStart;
+
+ // Slash commands: input starts with '/' and is still a single token.
+ if (text.StartsWith('/') && !text.Contains(' '))
+ {
+ var matches = _controller.GetCommandSuggestions(text);
+ if (ShowSuggestions(SuggestMode.Command, 0, caret,
+ matches.Select(m => new CommandSuggestion { Command = m.Command, Description = m.Description })))
+ return;
+ }
+
+ // @file references: find the token containing the caret; if it starts with '@',
+ // filter project files/directories through the same provider the CLI uses
+ // (directories come back with a trailing '/' — selecting one drills into it).
+ var tokenStart = caret;
+ while (tokenStart > 0 && !char.IsWhiteSpace(text[tokenStart - 1]))
+ tokenStart--;
+
+ if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == '@')
+ {
+ var fragment = text[(tokenStart + 1)..caret];
+ List matches;
+ try { matches = _fileProvider.FilterFiles(fragment); }
+ catch { matches = new List(); }
+
+ if (ShowSuggestions(SuggestMode.File, tokenStart, caret,
+ matches.Select(m => new CommandSuggestion
+ {
+ Command = m,
+ Description = m.EndsWith('/') ? "folder — select to drill in" : "file"
+ })))
+ return;
+ }
+
+ // :emoji: shortcodes (Slack-style). Two behaviors on the token containing the caret:
+ // - ":name:" fully typed with an exact match → replace it with the emoji right here.
+ // - ":fra" partially typed (2+ chars, no closing ':') → suggest matching shortcodes.
+ // The 2-char minimum keeps ordinary colons (":)", "note:") from popping the list.
+ if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == ':')
+ {
+ var body = text[(tokenStart + 1)..caret];
+ if (body.Length > 1 && body.EndsWith(':'))
+ {
+ var name = body[..^1].ToLowerInvariant();
+ var exact = EmojiShortcodes.FirstOrDefault(s => s.Name == name).Emoji;
+ if (exact != null)
+ {
+ InputBox.Text = text[..tokenStart] + exact + text[caret..];
+ InputBox.SelectionStart = tokenStart + exact.Length;
+ HideSuggestions();
+ return;
+ }
+ }
+ else if (body.Length >= 2 && !body.Contains(':'))
+ {
+ var frag = body.ToLowerInvariant();
+ var matches = EmojiShortcodes.Where(s => s.Name.StartsWith(frag))
+ .Concat(EmojiShortcodes.Where(s => !s.Name.StartsWith(frag) && s.Name.Contains(frag)));
+
+ if (ShowSuggestions(SuggestMode.Emoji, tokenStart, caret,
+ matches.Select(m => new CommandSuggestion
+ {
+ Command = ":" + m.Name + ":",
+ Description = m.Emoji,
+ InsertText = m.Emoji,
+ })))
+ return;
+ }
+ }
+
+ HideSuggestions();
+ }
+
+ private bool ShowSuggestions(SuggestMode mode, int tokenStart, int tokenEnd, IEnumerable items)
+ {
+ _suggestions.Clear();
+ foreach (var item in items) _suggestions.Add(item);
+ if (_suggestions.Count == 0) return false;
+
+ _suggestMode = mode;
+ _tokenStart = tokenStart;
+ _tokenEnd = tokenEnd;
+ SuggestionsPanel.Visibility = Visibility.Visible;
+ SuggestionsList.SelectedIndex = 0;
+ SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+ return true;
+ }
+
+ private void SuggestionsList_ItemClick(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is CommandSuggestion s) AcceptSuggestion(s);
+ }
+
+ private void AcceptSuggestion(CommandSuggestion s)
+ {
+ if (_suggestMode == SuggestMode.File)
+ {
+ var text = InputBox.Text;
+ var start = Math.Min(_tokenStart, text.Length);
+ var end = Math.Min(_tokenEnd, text.Length);
+
+ // Replace the @token with the picked path. Directories keep the caret hot
+ // (no trailing space) so the reopened popup shows their contents; files
+ // close the token with a space.
+ var isFolder = s.Command.EndsWith('/');
+ var replacement = "@" + s.Command + (isFolder ? "" : " ");
+ InputBox.Text = text[..start] + replacement + text[end..];
+ InputBox.SelectionStart = start + replacement.Length;
+
+ // Setting .Text resets the caret to 0 BEFORE the line above restores it, and
+ // TextChanged runs in that window — it sees no token at caret 0 and hides the
+ // popup. Recompute now that the caret is where the user expects it:
+ // folder → drilled listing reopens; file → token ended with a space, stays hidden.
+ UpdateSuggestions();
+ }
+ else if (_suggestMode == SuggestMode.Emoji)
+ {
+ var text = InputBox.Text;
+ var start = Math.Min(_tokenStart, text.Length);
+ var end = Math.Min(_tokenEnd, text.Length);
+ var emoji = s.InsertText ?? s.Command;
+ InputBox.Text = text[..start] + emoji + text[end..];
+ InputBox.SelectionStart = start + emoji.Length;
+ HideSuggestions();
+ }
+ else
+ {
+ InputBox.Text = s.Command + " ";
+ InputBox.SelectionStart = InputBox.Text.Length;
+ HideSuggestions();
+ }
+ InputBox.Focus(FocusState.Programmatic);
+ }
+
+ /// Curated quick-pick set for the emoji flyout; Win + . remains the full picker.
+ private static readonly string[] QuickEmojis =
+ {
+ "😀", "😄", "😂", "🤣", "😊", "😉", "😍", "🥰", "😎", "🤓", "🤔", "🙃",
+ "😅", "😬", "😭", "🥳", "🤯", "😴", "🙄", "😤", "😱", "🫠", "🤗", "🫡",
+ "👍", "👎", "👌", "🙏", "👏", "💪", "🤝", "✌️", "🤞", "👀", "🧠", "💯",
+ "🔥", "✨", "🚀", "🎉", "🎯", "💡", "⚡", "⭐", "❤️", "💔", "✅", "❌",
+ "⚠️", "❓", "❗", "💬", "🐛", "🔧", "🔒", "🔑", "📝", "📌", "📁", "🖥️",
+ "☕", "🍕", "🎮", "🤖",
+ };
+
+ /// Slack-style shortcode → emoji. Aliases are separate rows pointing at the same
+ /// emoji. Names must be lowercase; lookup lowercases the typed fragment.
+ private static readonly (string Name, string Emoji)[] EmojiShortcodes =
+ {
+ ("grinning", "😀"), ("smile", "😄"), ("joy", "😂"), ("rofl", "🤣"),
+ ("blush", "😊"), ("wink", "😉"), ("heart_eyes", "😍"), ("smiling_hearts", "🥰"),
+ ("sunglasses", "😎"), ("coolglasses", "😎"), ("nerd", "🤓"), ("thinking", "🤔"),
+ ("upside_down", "🙃"), ("sweat_smile", "😅"), ("grimacing", "😬"), ("sob", "😭"),
+ ("partying", "🥳"), ("mind_blown", "🤯"), ("sleeping", "😴"), ("eye_roll", "🙄"),
+ ("triumph", "😤"), ("scream", "😱"), ("melting", "🫠"), ("hugs", "🤗"),
+ ("salute", "🫡"), ("thumbsup", "👍"), ("+1", "👍"), ("thumbsdown", "👎"),
+ ("-1", "👎"), ("ok_hand", "👌"), ("pray", "🙏"), ("clap", "👏"),
+ ("muscle", "💪"), ("handshake", "🤝"), ("victory", "✌️"), ("crossed_fingers", "🤞"),
+ ("eyes", "👀"), ("brain", "🧠"), ("100", "💯"), ("fire", "🔥"),
+ ("sparkles", "✨"), ("rocket", "🚀"), ("tada", "🎉"), ("party_popper", "🎉"),
+ ("dart", "🎯"), ("bulb", "💡"), ("idea", "💡"), ("zap", "⚡"),
+ ("star", "⭐"), ("heart", "❤️"), ("broken_heart", "💔"), ("check", "✅"),
+ ("white_check_mark", "✅"), ("x", "❌"), ("cross", "❌"), ("warning", "⚠️"),
+ ("question", "❓"), ("exclamation", "❗"), ("speech_balloon", "💬"), ("bug", "🐛"),
+ ("wrench", "🔧"), ("lock", "🔒"), ("key", "🔑"), ("memo", "📝"),
+ ("note", "📝"), ("pushpin", "📌"), ("pin", "📌"), ("folder", "📁"),
+ ("desktop", "🖥️"), ("coffee", "☕"), ("pizza", "🍕"), ("video_game", "🎮"),
+ ("robot", "🤖"),
+ };
+
+ private void EmojiGrid_ItemClick(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is not string emoji || !InputBox.IsEnabled) return;
+ var caret = Math.Min(InputBox.SelectionStart, InputBox.Text.Length);
+ InputBox.Text = InputBox.Text.Insert(caret, emoji);
+ InputBox.SelectionStart = caret + emoji.Length;
+ InputBox.Focus(FocusState.Programmatic);
+ }
+
+ private void HideSuggestions()
+ {
+ _suggestMode = SuggestMode.None;
+ SuggestionsPanel.Visibility = Visibility.Collapsed;
+ _suggestions.Clear();
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
new file mode 100644
index 0000000..cbc6fc5
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
@@ -0,0 +1,251 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // Create-snapshot offer card — shown when the controller buffers a conversation (on a model
+ // switch or "Take snapshot"). The user picks a summarizer model and creates, or dismisses to
+ // discard. Snapshots are born summarized; there is no light/un-enhanced state.
+ // ============================================================
+
+ /// Shows or hides the top offer to match the controller's pending buffer. Stage 1 is the
+ /// thin notification bar; the full picker is built only when the user clicks Create on it.
+ private void RefreshSnapshotOffer()
+ {
+ var offer = _controller.PendingOffer;
+ if (offer == null)
+ {
+ SnapshotOfferRoot.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ // A manual "Take snapshot" is an explicit decision — skip the notification bar and open the
+ // name+model picker straight away. Model switches keep stage 1, since there the user may
+ // just want to keep working (or "keep memory") rather than snapshot at all.
+ if (offer.IsManual)
+ {
+ ShowSnapshotPickerCard(offer);
+ return;
+ }
+
+ // Stage 1: notification bar. Non-blocking — the user can ignore it and keep prompting.
+ // "Keep memory" only appears when a switch actually cleared a conversation.
+ SnapshotKeepMemoryButton.Visibility = _controller.CanCarryMemory
+ ? Visibility.Visible : Visibility.Collapsed;
+ SnapshotNotifyText.Text = _controller.CanCarryMemory
+ ? $"Keep the {offer.OriginModel} conversation going, or save it as a snapshot?"
+ : $"Snapshot available — save the {offer.OriginModel} conversation.";
+ SnapshotNotifyBar.Visibility = Visibility.Visible;
+ SnapshotOfferCard.Visibility = Visibility.Collapsed;
+ SnapshotOfferRoot.Visibility = Visibility.Visible;
+ SlideSnapshotOfferIn();
+ }
+
+ /// Stage 2: expand into the full name + model picker. Reached either from the
+ /// notification bar's Create (model switches) or directly for a manual "Take snapshot". Hangs at
+ /// the top until the user creates or dismisses.
+ private void ShowSnapshotPickerCard(ChatController.PendingSnapshot offer)
+ {
+ SnapshotOfferSubtitle.Text =
+ $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — "
+ + "name it (or leave blank and the summarizer will), pick a model, and create.";
+ SnapshotCreateButton.Content = "Create";
+ SnapshotNameBox.Text = ""; // a fresh offer starts unnamed
+ // Reset any leftover busy state from a prior, interrupted attempt.
+ SnapshotBusyPanel.Visibility = Visibility.Collapsed;
+ SnapshotBusyRing.IsActive = false;
+ SnapshotOfferContent.Opacity = 1;
+ SnapshotOfferContent.IsHitTestVisible = true;
+ SnapshotNotifyBar.Visibility = Visibility.Collapsed;
+ SnapshotOfferCard.Visibility = Visibility.Visible;
+ SnapshotOfferRoot.Visibility = Visibility.Visible;
+ SlideSnapshotOfferIn();
+ _ = LoadSnapshotModelsAsync(offer.OriginModel);
+ }
+
+ private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e)
+ {
+ var offer = _controller.PendingOffer;
+ if (offer != null) ShowSnapshotPickerCard(offer);
+ }
+
+ /// Drops the offer down from the top of the transcript with a short fade.
+ private void SlideSnapshotOfferIn()
+ {
+ var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+ var slide = new DoubleAnimation
+ {
+ From = -18, To = 0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(220)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(slide, SnapshotOfferTransform);
+ Storyboard.SetTargetProperty(slide, "Y");
+ var fade = new DoubleAnimation
+ {
+ From = 0, To = 1,
+ Duration = new Duration(TimeSpan.FromMilliseconds(180)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(fade, SnapshotOfferRoot);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+ var sb = new Storyboard();
+ sb.Children.Add(slide);
+ sb.Children.Add(fade);
+ sb.Begin();
+ }
+
+ /// Populates the model picker without making the card wait on a network round-trip: the
+ /// model that had the conversation is shown selected instantly, then the full installed-model list
+ /// (an Ollama /api/tags fetch, slow on cloud setups) streams in behind it for "pick another."
+ private async Task LoadSnapshotModelsAsync(string originModel)
+ {
+ // Instant: seed with just the current model so the card is usable with zero lag.
+ var current = new ModelChoice(originModel, MandoCodeConfig.IsCloudModel(originModel));
+ SnapshotModelCombo.ItemsSource = new List { current };
+ SnapshotModelCombo.SelectedIndex = 0;
+ SnapshotModelCombo.IsEnabled = true;
+ SnapshotCreateButton.IsEnabled = true;
+
+ // Background: fetch the rest so the dropdown fills in for choosing another model.
+ var result = await _controller.LoadAvailableModelsAsync();
+ if (!result.Ok || result.Models.Count == 0) return; // keep the single current entry
+
+ // Guard against a race: if a newer offer/switch swapped models while we were fetching, don't
+ // clobber its selection with this stale list.
+ if ((SnapshotModelCombo.SelectedItem as ModelChoice)?.Name != originModel) return;
+
+ var choices = result.Models
+ .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
+ .ToList();
+ if (!choices.Any(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase)))
+ choices.Insert(0, current); // keep the current model even if the list omits it
+
+ SnapshotModelCombo.ItemsSource = choices;
+ SnapshotModelCombo.SelectedItem =
+ choices.First(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async void SnapshotCreate_Click(object sender, RoutedEventArgs e)
+ {
+ if (SnapshotModelCombo.SelectedItem is not ModelChoice choice)
+ {
+ _transcript.Append(_html.Warn("Pick a model to summarize with first."));
+ return;
+ }
+
+ // Summarizing can take a while (especially a cloud model), so show a clear busy state:
+ // fade the controls out and spin, with the snapshot's name in the message when it has one.
+ var name = SnapshotNameBox.Text?.Trim() ?? "";
+ SnapshotBusyText.Text = string.IsNullOrEmpty(name)
+ ? "Creating snapshot…"
+ : $"Creating “{name}” snapshot…";
+ SetSnapshotBusy(true);
+
+ var error = await _controller.CreateSnapshotAsync(choice.Name, name);
+ if (error != null)
+ {
+ SetSnapshotBusy(false);
+ _transcript.Append(_html.Warn(error));
+ }
+ // On success the controller clears the offer → SnapshotOfferChanged → RefreshSnapshotOffer
+ // hides the whole thing, and a "Snapshot saved" chip lands in the transcript.
+ }
+
+ /// Toggles the create card's busy state: fades the inputs out (and blocks them) while a
+ /// centered spinner + "Creating…" text shows.
+ private void SetSnapshotBusy(bool busy)
+ {
+ SnapshotBusyRing.IsActive = busy;
+ SnapshotBusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ SnapshotOfferContent.IsHitTestVisible = !busy;
+
+ var fade = new DoubleAnimation
+ {
+ To = busy ? 0.25 : 1.0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(160)),
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+ };
+ Storyboard.SetTarget(fade, SnapshotOfferContent);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+ var sb = new Storyboard();
+ sb.Children.Add(fade);
+ sb.Begin();
+ }
+
+ private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
+ => _controller.DismissSnapshotOffer();
+
+ /// "Keep memory": verbatim continuation on the new model. On success the offer
+ /// clears itself (SnapshotOfferChanged → RefreshSnapshotOffer); on failure the bar stays
+ /// so Snapshot remains available as the salvage path.
+ private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
+ => _controller.TryCarryMemoryAcrossSwitch();
+
+ /// Saves this tab's transcript as a standalone HTML page. Shared by the header save
+ /// button and the tab's options menu.
+ public async Task ExportTranscriptAsync()
+ {
+ if (!CanScript) return;
+ try
+ {
+ var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML");
+ var html = JsonSerializer.Deserialize(json) ?? "";
+
+ var picker = new Windows.Storage.Pickers.FileSavePicker();
+ 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}";
+ var file = await picker.PickSaveFileAsync();
+ if (file == null) return;
+
+ await Windows.Storage.FileIO.WriteTextAsync(file, "\n" + html);
+ _transcript.Append(_html.Success($"Transcript saved to {file.Path}"));
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Warn($"Couldn't save transcript: {ex.Message}"));
+ }
+ }
+
+ /// Opens a clicked transcript path with its default app (folders open in Explorer).
+ /// Relative paths — how operation cards display them — resolve against THIS tab's root.
+ private void OpenTranscriptPath(string raw)
+ {
+ try
+ {
+ var path = raw.Trim();
+ if (!Path.IsPathRooted(path)) path = Path.Combine(Session.ProjectRoot.ProjectRoot, path);
+ path = Path.GetFullPath(path);
+ if (File.Exists(path) || Directory.Exists(path))
+ {
+ if (ShellOpen.Try(path) is { } ex1)
+ _transcript.Append(_html.Warn($"Couldn't open file: {ex1.Message}"));
+ }
+ else
+ _transcript.Append(_html.Warn($"Can't open — no longer exists: {path}"));
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+ }
+ }
+
+ private static void OpenInBrowser(string url)
+ => ShellOpen.Try(url); // a dead link must not crash the app — the launch failure is swallowed
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
new file mode 100644
index 0000000..8af92a7
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
@@ -0,0 +1,168 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+ // ============================================================
+ // Transcript
+ // ============================================================
+
+ private bool _journalRestored;
+
+ /// Replays this session's journaled transcript into the fresh WebView — via
+ /// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every
+ /// block). Chunked so a long history is a few script calls, not a thousand.
+ private async Task RestoreJournaledTranscriptAsync()
+ {
+ if (_journalRestored) return;
+ _journalRestored = true;
+ try
+ {
+ var blocks = TranscriptJournal.Load(Session.PersistKey)
+ .Where(b => !TranscriptHtmlBuilder.IsEphemeralStatus(b))
+ .ToList();
+ if (blocks.Count == 0) return;
+
+ var chunk = new System.Text.StringBuilder();
+ foreach (var block in blocks)
+ {
+ chunk.Append(block);
+ if (chunk.Length > 400_000)
+ {
+ await AppendRawAsync(chunk.ToString());
+ chunk.Clear();
+ }
+ }
+ if (chunk.Length > 0) await AppendRawAsync(chunk.ToString());
+
+ // Divider goes through AppendRawAsync too — journaling it would stack one
+ // divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync,
+ // called by MainWindow after any saved model is re-selected — a model switch clears
+ // history, so restoring memory here would risk it being wiped moments later).
+ _replayedBlockCount = blocks.Count;
+ await AppendRawAsync(_html.Dim("— restored from your previous session —"));
+ }
+ catch { /* a failed replay must never block a fresh conversation */ }
+ }
+
+ private int _replayedBlockCount;
+
+ ///
+ /// Gives the restored session its memory back, best fidelity first. Called by MainWindow
+ /// AFTER the tab's harness is initialized and any saved model re-selected. Cascade:
+ /// 1) full-fidelity harness history (the agent genuinely remembers, tool calls included);
+ /// 2) plain-text tail armed as imported background (briefed, not remembering);
+ /// 3) an honest amnesia note, so the model never has to guess about the replayed pixels.
+ /// Fresh tabs have none of the files and fall straight through as a no-op.
+ ///
+ public async Task RestoreConversationMemoryAsync()
+ {
+ try
+ {
+ // 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
+ var historyJson = SessionHistoryStore.Load(Session.PersistKey);
+ if (historyJson != null)
+ {
+ var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson));
+ if (restored > 0)
+ {
+ await AppendRawAsync(_html.Dim(
+ $"Conversation memory restored — the agent remembers this session ({restored} messages)."));
+ return;
+ }
+ }
+
+ // 2) Tail-brief: bounded verbatim excerpt rides the next send as imported background.
+ var turns = ConversationLog.Load(Session.PersistKey);
+ if (turns.Count > 0)
+ {
+ const int budget = 12_000;
+ var picked = new List();
+ var used = 0;
+ for (var i = turns.Count - 1; i >= 0; i--)
+ {
+ if (picked.Count > 0 && used + turns[i].T.Length > budget) break;
+ picked.Add(turns[i]);
+ used += turns[i].T.Length;
+ }
+ picked.Reverse();
+
+ var sb = new System.Text.StringBuilder();
+ if (picked.Count < turns.Count)
+ sb.Append($"(Older turns omitted — this is the most recent {picked.Count} of {turns.Count}.)\n\n");
+ foreach (var turn in picked)
+ sb.Append(turn.R == "u" ? "User: " : "Assistant: ").Append(turn.T).Append("\n\n");
+
+ _controller.ArmRestoredConversation(
+ "From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" +
+ sb.ToString().TrimEnd());
+ await AppendRawAsync(_html.Dim(
+ "Context re-armed — the agent will be briefed on this conversation with your next message."));
+ return;
+ }
+
+ // 3) Transcript was replayed but no memory of any kind exists — say so to the model.
+ if (_replayedBlockCount > 0)
+ _controller.NoteWorkspaceEvent(
+ "This tab was restored from a previous session. The transcript the user sees above is a replay " +
+ "for their benefit; it is NOT in your context and you have no memory of it. If the user refers " +
+ "to earlier work, say so honestly and re-read files instead of guessing.");
+ }
+ catch { /* memory restore is best-effort; a fresh conversation always works */ }
+ }
+
+ private async Task AppendRawAsync(string html)
+ {
+ try
+ {
+ await TranscriptView.CoreWebView2.ExecuteScriptAsync(
+ $"window.__append({JsonSerializer.Serialize(html)})");
+ }
+ catch { /* transient during navigation/teardown — a fragment failing to render is not fatal */ }
+ }
+
+ private async void AppendHtml(string html)
+ {
+ if (_shutDown) return;
+ if (!CanScript)
+ {
+ _pendingHtml.Enqueue(html);
+ return;
+ }
+
+ try
+ {
+ await TranscriptView.CoreWebView2.ExecuteScriptAsync(
+ $"window.__append({JsonSerializer.Serialize(html)})");
+ }
+ catch
+ {
+ // A fragment failing to render must never take the app down.
+ }
+ }
+
+ private async void ClearTranscript()
+ {
+ if (!CanScript) return;
+ try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); }
+ catch { /* transient during navigation/teardown — clearing a gone WebView is a no-op */ }
+ }
+
+ /// Offer to snapshot this tab's conversation (the "Take snapshot" tab action) — pops the
+ /// opt-in create card so the user can pick a summarizer model.
+ public void TakeSnapshotManually() => _ = _controller.OfferManualSnapshotAsync();
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs b/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs
new file mode 100644
index 0000000..10363d1
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs
@@ -0,0 +1,118 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+/// One row in the header's model dropdown: the model tag plus a cloud/local badge.
+/// Built on the UI thread when the flyout opens, so it can carry ready-made brushes.
+public sealed class ModelItem
+{
+ public ModelItem(string name, string badge, Brush badgeForeground, Brush badgeBackground)
+ {
+ Name = name;
+ Badge = badge;
+ BadgeForeground = badgeForeground;
+ BadgeBackground = badgeBackground;
+ }
+
+ public string Name { get; }
+ public string Badge { get; }
+ public Brush BadgeForeground { get; }
+ public Brush BadgeBackground { get; }
+}
+
+/// One row in the file-explorer tree. Folder nodes are created with unrealized
+/// children and lazy-load their contents on first expand (ChatTabView.ExplorerTree_Expanding).
+public sealed class ExplorerItem : System.ComponentModel.INotifyPropertyChanged
+{
+ public string Name { get; private init; } = "";
+ public string FullPath { get; private init; } = "";
+ public bool IsDirectory { get; private init; }
+
+ /// Root-relative path with forward slashes \u2014 the key used to match this row
+ /// against git change entries.
+ public string RelPath { get; private init; } = "";
+
+ /// The exact @token the row produces (root-relative, forward slashes, trailing
+ /// '/' on folders) \u2014 shown in the tag button's tooltip so hovering teaches the @ syntax.
+ public string Token { get; private init; } = "";
+
+ public string TagTooltip => $"Tag in prompt \u2014 inserts {Token}";
+
+ /// Files: this file has uncommitted changes. Folders: something inside does.
+ /// Mutable + observable so rows already realized in the tree light up in place when a
+ /// git refresh lands (rebuilding the tree would lose expansion state).
+ public bool Dirty
+ {
+ get => _dirty;
+ set
+ {
+ if (_dirty == value) return;
+ _dirty = value;
+ PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DirtyVisibility)));
+ }
+ }
+ private bool _dirty;
+
+ public Visibility DirtyVisibility => _dirty ? Visibility.Visible : Visibility.Collapsed;
+
+ public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
+
+ public string Glyph => IsDirectory ? "\uE8B7" : "\uE8A5"; // folder / document
+
+ /// Resolved per-realization from app resources, so icons pick up live theme
+ /// switches the next time rows are created (matching how transcript colors retheme).
+ public Brush? IconBrush =>
+ Application.Current.Resources[IsDirectory ? "MandoGoldBrush" : "MandoDimBrush"] as Brush;
+
+ public static ExplorerItem ForFolder(string path, string root)
+ {
+ var rel = Rel(path, root);
+ return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, RelPath = rel, Token = "@" + rel + "/" };
+ }
+
+ public static ExplorerItem ForFile(string path, string root)
+ {
+ var rel = Rel(path, root);
+ return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, RelPath = rel, Token = "@" + rel };
+ }
+
+ private static string Rel(string path, string root) =>
+ Path.GetRelativePath(root, path).Replace('\\', '/');
+}
+
+/// One row in the explorer's Changes tab: a working-tree change with its display
+/// letter/color, split name + directory, and the @token its tag button inserts. Built on
+/// the UI thread from a GitQuickStatus snapshot, so it carries ready-made brushes
+/// (same pattern as ModelItem).
+public sealed class GitChangeItem
+{
+ public string Kind { get; init; } = "";
+ public string KindLabel { get; init; } = "";
+ public Brush? KindBrush { get; init; }
+ public string Name { get; init; } = "";
+ public string Dir { get; init; } = "";
+ public string FullPath { get; init; } = "";
+ public string RelPath { get; init; } = "";
+ public string TagTooltip { get; init; } = "";
+
+ /// Undo restores from HEAD, so it needs a HEAD side: hidden for untracked rows
+ /// ("undoing" a new file would DELETE it — different action, different UI) and renamed
+ /// rows (a clean rename-undo needs both paths).
+ public Visibility UndoVisibility => Kind is "M" or "D" or "!" ? Visibility.Visible : Visibility.Collapsed;
+
+ public string UndoTooltip => Kind == "D"
+ ? "Restore this deleted file"
+ : "Undo changes — restore this file to the last commit (asks first)";
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
index a30dc6f..95970ec 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
@@ -549,7 +549,7 @@
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index ff8730c..ad2e63d 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -348,2090 +348,4 @@ public void HandleEscape()
_controller.CancelActiveRequest();
}
- // ============================================================
- // Transcript
- // ============================================================
-
- private bool _journalRestored;
-
- /// Replays this session's journaled transcript into the fresh WebView — via
- /// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every
- /// block). Chunked so a long history is a few script calls, not a thousand.
- private async Task RestoreJournaledTranscriptAsync()
- {
- if (_journalRestored) return;
- _journalRestored = true;
- try
- {
- var blocks = TranscriptJournal.Load(Session.PersistKey)
- .Where(b => !TranscriptHtmlBuilder.IsEphemeralStatus(b))
- .ToList();
- if (blocks.Count == 0) return;
-
- var chunk = new System.Text.StringBuilder();
- foreach (var block in blocks)
- {
- chunk.Append(block);
- if (chunk.Length > 400_000)
- {
- await AppendRawAsync(chunk.ToString());
- chunk.Clear();
- }
- }
- if (chunk.Length > 0) await AppendRawAsync(chunk.ToString());
-
- // Divider goes through AppendRawAsync too — journaling it would stack one
- // divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync,
- // called by MainWindow after any saved model is re-selected — a model switch clears
- // history, so restoring memory here would risk it being wiped moments later).
- _replayedBlockCount = blocks.Count;
- await AppendRawAsync(_html.Dim("— restored from your previous session —"));
- }
- catch { /* a failed replay must never block a fresh conversation */ }
- }
-
- private int _replayedBlockCount;
-
- ///
- /// Gives the restored session its memory back, best fidelity first. Called by MainWindow
- /// AFTER the tab's harness is initialized and any saved model re-selected. Cascade:
- /// 1) full-fidelity harness history (the agent genuinely remembers, tool calls included);
- /// 2) plain-text tail armed as imported background (briefed, not remembering);
- /// 3) an honest amnesia note, so the model never has to guess about the replayed pixels.
- /// Fresh tabs have none of the files and fall straight through as a no-op.
- ///
- public async Task RestoreConversationMemoryAsync()
- {
- try
- {
- // 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
- var historyJson = SessionHistoryStore.Load(Session.PersistKey);
- if (historyJson != null)
- {
- var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson));
- if (restored > 0)
- {
- await AppendRawAsync(_html.Dim(
- $"Conversation memory restored — the agent remembers this session ({restored} messages)."));
- return;
- }
- }
-
- // 2) Tail-brief: bounded verbatim excerpt rides the next send as imported background.
- var turns = ConversationLog.Load(Session.PersistKey);
- if (turns.Count > 0)
- {
- const int budget = 12_000;
- var picked = new List();
- var used = 0;
- for (var i = turns.Count - 1; i >= 0; i--)
- {
- if (picked.Count > 0 && used + turns[i].T.Length > budget) break;
- picked.Add(turns[i]);
- used += turns[i].T.Length;
- }
- picked.Reverse();
-
- var sb = new System.Text.StringBuilder();
- if (picked.Count < turns.Count)
- sb.Append($"(Older turns omitted — this is the most recent {picked.Count} of {turns.Count}.)\n\n");
- foreach (var turn in picked)
- sb.Append(turn.R == "u" ? "User: " : "Assistant: ").Append(turn.T).Append("\n\n");
-
- _controller.ArmRestoredConversation(
- "From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" +
- sb.ToString().TrimEnd());
- await AppendRawAsync(_html.Dim(
- "Context re-armed — the agent will be briefed on this conversation with your next message."));
- return;
- }
-
- // 3) Transcript was replayed but no memory of any kind exists — say so to the model.
- if (_replayedBlockCount > 0)
- _controller.NoteWorkspaceEvent(
- "This tab was restored from a previous session. The transcript the user sees above is a replay " +
- "for their benefit; it is NOT in your context and you have no memory of it. If the user refers " +
- "to earlier work, say so honestly and re-read files instead of guessing.");
- }
- catch { /* memory restore is best-effort; a fresh conversation always works */ }
- }
-
- private async Task AppendRawAsync(string html)
- {
- try
- {
- await TranscriptView.CoreWebView2.ExecuteScriptAsync(
- $"window.__append({JsonSerializer.Serialize(html)})");
- }
- catch { }
- }
-
- private async void AppendHtml(string html)
- {
- if (_shutDown) return;
- if (!CanScript)
- {
- _pendingHtml.Enqueue(html);
- return;
- }
-
- try
- {
- await TranscriptView.CoreWebView2.ExecuteScriptAsync(
- $"window.__append({JsonSerializer.Serialize(html)})");
- }
- catch
- {
- // A fragment failing to render must never take the app down.
- }
- }
-
- private async void ClearTranscript()
- {
- if (!CanScript) return;
- try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); }
- catch { }
- }
-
- /// Offer to snapshot this tab's conversation (the "Take snapshot" tab action) — pops the
- /// opt-in create card so the user can pick a summarizer model.
- public void TakeSnapshotManually() => _ = _controller.OfferManualSnapshotAsync();
-
- // ============================================================
- // Create-snapshot offer card — shown when the controller buffers a conversation (on a model
- // switch or "Take snapshot"). The user picks a summarizer model and creates, or dismisses to
- // discard. Snapshots are born summarized; there is no light/un-enhanced state.
- // ============================================================
-
- /// Shows or hides the top offer to match the controller's pending buffer. Stage 1 is the
- /// thin notification bar; the full picker is built only when the user clicks Create on it.
- private void RefreshSnapshotOffer()
- {
- var offer = _controller.PendingOffer;
- if (offer == null)
- {
- SnapshotOfferRoot.Visibility = Visibility.Collapsed;
- return;
- }
-
- // A manual "Take snapshot" is an explicit decision — skip the notification bar and open the
- // name+model picker straight away. Model switches keep stage 1, since there the user may
- // just want to keep working (or "keep memory") rather than snapshot at all.
- if (offer.IsManual)
- {
- ShowSnapshotPickerCard(offer);
- return;
- }
-
- // Stage 1: notification bar. Non-blocking — the user can ignore it and keep prompting.
- // "Keep memory" only appears when a switch actually cleared a conversation.
- SnapshotKeepMemoryButton.Visibility = _controller.CanCarryMemory
- ? Visibility.Visible : Visibility.Collapsed;
- SnapshotNotifyText.Text = _controller.CanCarryMemory
- ? $"Keep the {offer.OriginModel} conversation going, or save it as a snapshot?"
- : $"Snapshot available — save the {offer.OriginModel} conversation.";
- SnapshotNotifyBar.Visibility = Visibility.Visible;
- SnapshotOfferCard.Visibility = Visibility.Collapsed;
- SnapshotOfferRoot.Visibility = Visibility.Visible;
- SlideSnapshotOfferIn();
- }
-
- /// Stage 2: expand into the full name + model picker. Reached either from the
- /// notification bar's Create (model switches) or directly for a manual "Take snapshot". Hangs at
- /// the top until the user creates or dismisses.
- private void ShowSnapshotPickerCard(ChatController.PendingSnapshot offer)
- {
- SnapshotOfferSubtitle.Text =
- $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — "
- + "name it (or leave blank and the summarizer will), pick a model, and create.";
- SnapshotCreateButton.Content = "Create";
- SnapshotNameBox.Text = ""; // a fresh offer starts unnamed
- // Reset any leftover busy state from a prior, interrupted attempt.
- SnapshotBusyPanel.Visibility = Visibility.Collapsed;
- SnapshotBusyRing.IsActive = false;
- SnapshotOfferContent.Opacity = 1;
- SnapshotOfferContent.IsHitTestVisible = true;
- SnapshotNotifyBar.Visibility = Visibility.Collapsed;
- SnapshotOfferCard.Visibility = Visibility.Visible;
- SnapshotOfferRoot.Visibility = Visibility.Visible;
- SlideSnapshotOfferIn();
- _ = LoadSnapshotModelsAsync(offer.OriginModel);
- }
-
- private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e)
- {
- var offer = _controller.PendingOffer;
- if (offer != null) ShowSnapshotPickerCard(offer);
- }
-
- /// Drops the offer down from the top of the transcript with a short fade.
- private void SlideSnapshotOfferIn()
- {
- var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
- var slide = new DoubleAnimation
- {
- From = -18, To = 0,
- Duration = new Duration(TimeSpan.FromMilliseconds(220)),
- EasingFunction = ease,
- };
- Storyboard.SetTarget(slide, SnapshotOfferTransform);
- Storyboard.SetTargetProperty(slide, "Y");
- var fade = new DoubleAnimation
- {
- From = 0, To = 1,
- Duration = new Duration(TimeSpan.FromMilliseconds(180)),
- EasingFunction = ease,
- };
- Storyboard.SetTarget(fade, SnapshotOfferRoot);
- Storyboard.SetTargetProperty(fade, "Opacity");
- var sb = new Storyboard();
- sb.Children.Add(slide);
- sb.Children.Add(fade);
- sb.Begin();
- }
-
- /// Populates the model picker without making the card wait on a network round-trip: the
- /// model that had the conversation is shown selected instantly, then the full installed-model list
- /// (an Ollama /api/tags fetch, slow on cloud setups) streams in behind it for "pick another."
- private async Task LoadSnapshotModelsAsync(string originModel)
- {
- // Instant: seed with just the current model so the card is usable with zero lag.
- var current = new ModelChoice(originModel, MandoCodeConfig.IsCloudModel(originModel));
- SnapshotModelCombo.ItemsSource = new List { current };
- SnapshotModelCombo.SelectedIndex = 0;
- SnapshotModelCombo.IsEnabled = true;
- SnapshotCreateButton.IsEnabled = true;
-
- // Background: fetch the rest so the dropdown fills in for choosing another model.
- var result = await _controller.LoadAvailableModelsAsync();
- if (!result.Ok || result.Models.Count == 0) return; // keep the single current entry
-
- // Guard against a race: if a newer offer/switch swapped models while we were fetching, don't
- // clobber its selection with this stale list.
- if ((SnapshotModelCombo.SelectedItem as ModelChoice)?.Name != originModel) return;
-
- var choices = result.Models
- .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
- .ToList();
- if (!choices.Any(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase)))
- choices.Insert(0, current); // keep the current model even if the list omits it
-
- SnapshotModelCombo.ItemsSource = choices;
- SnapshotModelCombo.SelectedItem =
- choices.First(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase));
- }
-
- private async void SnapshotCreate_Click(object sender, RoutedEventArgs e)
- {
- if (SnapshotModelCombo.SelectedItem is not ModelChoice choice)
- {
- _transcript.Append(_html.Warn("Pick a model to summarize with first."));
- return;
- }
-
- // Summarizing can take a while (especially a cloud model), so show a clear busy state:
- // fade the controls out and spin, with the snapshot's name in the message when it has one.
- var name = SnapshotNameBox.Text?.Trim() ?? "";
- SnapshotBusyText.Text = string.IsNullOrEmpty(name)
- ? "Creating snapshot…"
- : $"Creating “{name}” snapshot…";
- SetSnapshotBusy(true);
-
- var error = await _controller.CreateSnapshotAsync(choice.Name, name);
- if (error != null)
- {
- SetSnapshotBusy(false);
- _transcript.Append(_html.Warn(error));
- }
- // On success the controller clears the offer → SnapshotOfferChanged → RefreshSnapshotOffer
- // hides the whole thing, and a "Snapshot saved" chip lands in the transcript.
- }
-
- /// Toggles the create card's busy state: fades the inputs out (and blocks them) while a
- /// centered spinner + "Creating…" text shows.
- private void SetSnapshotBusy(bool busy)
- {
- SnapshotBusyRing.IsActive = busy;
- SnapshotBusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
- SnapshotOfferContent.IsHitTestVisible = !busy;
-
- var fade = new DoubleAnimation
- {
- To = busy ? 0.25 : 1.0,
- Duration = new Duration(TimeSpan.FromMilliseconds(160)),
- EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
- };
- Storyboard.SetTarget(fade, SnapshotOfferContent);
- Storyboard.SetTargetProperty(fade, "Opacity");
- var sb = new Storyboard();
- sb.Children.Add(fade);
- sb.Begin();
- }
-
- private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
- => _controller.DismissSnapshotOffer();
-
- /// "Keep memory": verbatim continuation on the new model. On success the offer
- /// clears itself (SnapshotOfferChanged → RefreshSnapshotOffer); on failure the bar stays
- /// so Snapshot remains available as the salvage path.
- private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
- => _controller.TryCarryMemoryAcrossSwitch();
-
- /// Saves this tab's transcript as a standalone HTML page. Shared by the header save
- /// button and the tab's options menu.
- public async Task ExportTranscriptAsync()
- {
- if (!CanScript) return;
- try
- {
- var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML");
- var html = JsonSerializer.Deserialize(json) ?? "";
-
- var picker = new Windows.Storage.Pickers.FileSavePicker();
- 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}";
- var file = await picker.PickSaveFileAsync();
- if (file == null) return;
-
- await Windows.Storage.FileIO.WriteTextAsync(file, "\n" + html);
- _transcript.Append(_html.Success($"Transcript saved to {file.Path}"));
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Warn($"Couldn't save transcript: {ex.Message}"));
- }
- }
-
- /// Opens a clicked transcript path with its default app (folders open in Explorer).
- /// Relative paths — how operation cards display them — resolve against THIS tab's root.
- private void OpenTranscriptPath(string raw)
- {
- try
- {
- var path = raw.Trim();
- if (!Path.IsPathRooted(path)) path = Path.Combine(Session.ProjectRoot.ProjectRoot, path);
- path = Path.GetFullPath(path);
- if (File.Exists(path) || Directory.Exists(path))
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
- else
- _transcript.Append(_html.Warn($"Can't open — no longer exists: {path}"));
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
- }
- }
-
- private static void OpenInBrowser(string url)
- {
- try
- {
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
- }
- catch { /* a dead link must not crash the app */ }
- }
-
- // ============================================================
- // Header / busy / plan progress
- // ============================================================
-
- public void UpdateHeader()
- {
- ModelText.Text = _controller.ModelName;
- ProjectRootText.Text = _controller.ProjectRootPath;
- ConnectionDot.Fill = new SolidColorBrush(
- _controller.ModelError ? Colors.Orange
- : _controller.IsConnected ? Colors.LimeGreen
- : Colors.Gray);
-
- var tracker = Session.Tokens;
- TokenText.Text = tracker.TotalSessionTokens > 0
- ? $"{MandoCode.Services.TokenTrackingService.FormatTokenCount(tracker.TotalSessionTokens)} tokens"
- : "";
-
- var processing = _controller.IsProcessing;
- SendIcon.Glyph = processing ? "" : ""; // stop vs send
- SendLabel.Text = processing ? "Stop" : "Send";
- ModelButton.IsEnabled = !processing; // no model switch mid-turn
-
- RefreshBranchChip();
-
- HeaderChanged?.Invoke(this);
- }
-
- private void UpdateBusy(bool busy, string? activity)
- {
- BusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
- BusyRing.IsActive = busy;
- if (busy) BusyText.Text = string.IsNullOrWhiteSpace(activity) ? "Working..." : activity;
- else
- {
- // Turn just ended: refresh git state and snapshot it as the baseline for
- // detecting OUTSIDE-the-conversation changes before the next send.
- _wsTracker.MarkCapturePending();
- RefreshBranchChip(force: true);
-
- // Persist the model's full memory as of this turn (tier-3 full fidelity).
- // Off-thread: serialization of a long history shouldn't touch UI latency.
- var key = Session.PersistKey;
- var ai = Session.Ai;
- _ = Task.Run(() => SessionHistoryStore.Save(key, ai.ExportHistoryJson()));
- }
- }
-
- // ============================================================
- // Workspace-change notes for the model
- // ============================================================
- // The model only knows what happened inside the conversation. Anything else — the undo
- // button discarding its edits, files changed in another editor, external branch switches
- // — is invisible to it and leaves its picture of the working tree stale. The decision
- // logic lives in WorkspaceDeltaTracker (pure, unit-testable); this class only feeds it:
- // turn end → MarkCapturePending, git refresh → CaptureBaselineIfPending, watcher touch →
- // RecordTouch, send → EmitDelta. Notes queue on the controller (same pattern as reactions).
-
- private GitBranchInfo? _lastGitInfo;
- private readonly WorkspaceDeltaTracker _wsTracker = new();
-
- /// Called at send time: queues notes for whatever changed outside the
- /// conversation since the last turn ended, then re-baselines.
- private void EmitWorkspaceDelta()
- {
- foreach (var note in _wsTracker.EmitDelta(_lastGitInfo))
- _controller.NoteWorkspaceEvent(note);
- }
-
- // ============================================================
- // Git status strip
- // ============================================================
-
- private int _branchRefreshSeq;
- private DateTime _lastBranchRefresh = DateTime.MinValue;
- private string? _lastGitRoot;
- private readonly ObservableCollection _changes = new();
-
- /// Fire-and-forget refresh of the bottom status strip AND the explorer's Changes
- /// tab (one git call feeds both). Throttled (UpdateHeader runs on every controller state
- /// change) except when the root changed; sequence-guarded so an older, slower git call
- /// can never overwrite a newer result; any failure just hides the strip.
- private async void RefreshBranchChip(bool force = false)
- {
- var root = _controller.ProjectRootPath;
- if (root != _lastGitRoot) force = true; // never show the previous folder's state
- if (!force && (DateTime.UtcNow - _lastBranchRefresh).TotalSeconds < 2) return;
- _lastBranchRefresh = DateTime.UtcNow;
- _lastGitRoot = root;
-
- var seq = ++_branchRefreshSeq;
- var info = await Task.Run(() => GitQuickStatus.TryGet(root));
-
- if (_shutDown || seq != _branchRefreshSeq) return;
- _lastGitInfo = info;
- UpdateChangesList(info, root);
- _wsTracker.CaptureBaselineIfPending(info);
- if (info == null)
- {
- StatusStrip.Visibility = Visibility.Collapsed;
- return;
- }
-
- BranchText.Text = info.Branch
- + (info.Ahead > 0 ? $" ↑{info.Ahead}" : "")
- + (info.Behind > 0 ? $" ↓{info.Behind}" : "");
-
- // One status light: conflicts trump dirty trumps clean.
- var (dotBrush, state) =
- info.Conflicted ? ("MandoRedBrush", "merge conflicts")
- : info.Dirty ? ("MandoGoldBrush", "uncommitted changes")
- : ("MandoGreenBrush", "clean");
- BranchDot.Fill = Application.Current.Resources[dotBrush] as Brush;
-
- var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
- Path.TrimEndingDirectorySeparator(info.RepoRoot),
- Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
- ToolTipService.SetToolTip(StatusStrip,
- (info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
- + " — " + state
- + (info.Ahead > 0 || info.Behind > 0
- ? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
- // Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
- + (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
- StatusStrip.Visibility = Visibility.Visible;
- }
-
- /// Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).
- private void UpdateChangesList(GitBranchInfo? info, string root)
- {
- if (ChangesList.ItemsSource == null) ChangesList.ItemsSource = _changes;
-
- // Rebuilding the collection re-realizes every ListView row — a visible flash — so
- // bail when this snapshot is identical to what's already shown (the common case:
- // most refreshes confirm state rather than change it). Badges derive from the same
- // data, so they can't have changed either.
- var incoming = info?.Changes ?? (IReadOnlyList)Array.Empty();
- if (incoming.Count == _changes.Count)
- {
- var identical = true;
- for (var i = 0; i < incoming.Count; i++)
- {
- if (incoming[i].RelPath != _changes[i].RelPath || incoming[i].Kind != _changes[i].Kind)
- {
- identical = false;
- break;
- }
- }
- if (identical) return;
- }
-
- _changes.Clear();
- if (info != null)
- {
- foreach (var c in info.Changes)
- {
- var relNative = c.RelPath.Replace('/', Path.DirectorySeparatorChar);
- _changes.Add(new GitChangeItem
- {
- Kind = c.Kind,
- KindBrush = BrushForKind(c.Kind),
- KindLabel = c.Kind switch
- {
- "!" => "Merge conflict",
- "U" => "Untracked (new, not yet added)",
- "A" => "Added",
- "D" => "Deleted",
- "R" => "Renamed",
- _ => "Modified",
- },
- Name = Path.GetFileName(c.RelPath.TrimEnd('/')),
- Dir = Path.GetDirectoryName(relNative)?.Replace(Path.DirectorySeparatorChar, '/') ?? "",
- FullPath = Path.Combine(root, relNative),
- RelPath = c.RelPath,
- TagTooltip = $"Tag in prompt — inserts @{c.RelPath}",
- });
- }
- }
-
- ChangesTabButton.Content = _changes.Count > 0 ? $"Changes ({_changes.Count})" : "Changes";
- ChangesEmptyText.Visibility = _changesTabActive && _changes.Count == 0
- ? Visibility.Visible : Visibility.Collapsed;
- CommitButton.IsEnabled = _changes.Count > 0;
-
- RebuildDirtySets(info);
- RefreshExplorerDirtyFlags();
- }
-
- // --- dirty badges on the file tree ---
- // A changed file gets a gold dot; every ancestor folder gets one too, so a collapsed
- // folder still signals "something inside changed" (VS Code's badge behavior).
-
- private readonly HashSet _gitDirtyFiles = new(StringComparer.OrdinalIgnoreCase);
- private readonly HashSet _gitDirtyDirs = new(StringComparer.OrdinalIgnoreCase);
-
- private void RebuildDirtySets(GitBranchInfo? info)
- {
- _gitDirtyFiles.Clear();
- _gitDirtyDirs.Clear();
- if (info == null) return;
- foreach (var c in info.Changes)
- {
- var rel = c.RelPath.TrimEnd('/');
- // Untracked directories arrive as one "dir/" entry — that's a dir badge, not a file.
- if (c.RelPath.EndsWith('/')) _gitDirtyDirs.Add(rel);
- else _gitDirtyFiles.Add(rel);
- for (var slash = rel.LastIndexOf('/'); slash > 0; slash = rel.LastIndexOf('/'))
- {
- rel = rel[..slash];
- _gitDirtyDirs.Add(rel);
- }
- }
- }
-
- /// Re-flags every REALIZED tree node in place (expansion state survives).
- /// Nodes created later pick their flag up at creation in LoadChildNodes.
- private void RefreshExplorerDirtyFlags()
- {
- Walk(ExplorerTree.RootNodes);
-
- void Walk(IList nodes)
- {
- foreach (var node in nodes)
- {
- if (node.Content is ExplorerItem item) item.Dirty = IsItemDirty(item);
- if (node.Children.Count > 0) Walk(node.Children);
- }
- }
- }
-
- private bool IsItemDirty(ExplorerItem item) =>
- item.IsDirectory ? _gitDirtyDirs.Contains(item.RelPath) : _gitDirtyFiles.Contains(item.RelPath);
-
- private static Brush? BrushForKind(string kind) =>
- Application.Current.Resources[kind switch
- {
- "!" or "D" => "MandoRedBrush",
- "A" or "U" => "MandoGreenBrush",
- "R" => "MandoSkyBrush",
- _ => "MandoGoldBrush",
- }] as Brush;
-
- private void UpdatePlanProgress(int done, int total, bool active)
- {
- PlanProgressPanel.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
- if (total > 0)
- {
- PlanProgressBar.Value = done * 100.0 / total;
- PlanProgressText.Text = $"Plan: step {Math.Min(done + 1, total)} of {total}";
- }
- }
-
- ///
- /// Populates the model dropdown each time it opens. The flyout appears immediately showing a
- /// loading spinner; this awaits the model list off the UI thread and swaps in the rows (or an
- /// inline error) when it returns. Tab-local — picking a model repins THIS agent only.
- ///
- private async void ModelFlyout_Opening(object? sender, object e)
- {
- ModelLoadingPanel.Visibility = Visibility.Visible;
- ModelErrorText.Visibility = Visibility.Collapsed;
- ModelList.Visibility = Visibility.Collapsed;
-
- var result = await _controller.LoadAvailableModelsAsync();
-
- if (!result.Ok)
- {
- ModelErrorText.Text = result.Error;
- ModelLoadingPanel.Visibility = Visibility.Collapsed;
- ModelErrorText.Visibility = Visibility.Visible;
- return;
- }
-
- var sky = (Brush)Application.Current.Resources["MandoSkyBrush"];
- var dim = (Brush)Application.Current.Resources["MandoDimBrush"];
- var badgeBg = new SolidColorBrush(Windows.UI.Color.FromArgb(0x22, 0x80, 0x80, 0x80));
- var current = _controller.ModelName;
-
- var items = result.Models.Select(m =>
- {
- var cloud = MandoCodeConfig.IsCloudModel(m);
- return new ModelItem(m, cloud ? "cloud" : "local", cloud ? sky : dim, badgeBg);
- }).ToList();
-
- ModelList.ItemsSource = items;
- ModelList.SelectedItem = items.FirstOrDefault(
- i => string.Equals(i.Name, current, StringComparison.OrdinalIgnoreCase));
-
- ModelLoadingPanel.Visibility = Visibility.Collapsed;
- ModelList.Visibility = Visibility.Visible;
- }
-
- private async void ModelList_ItemClick(object sender, ItemClickEventArgs e)
- {
- ModelFlyout.Hide();
- if (e.ClickedItem is not ModelItem item) return;
- if (string.Equals(item.Name, _controller.ModelName, StringComparison.OrdinalIgnoreCase)) return;
-
- await Task.Run(() => _controller.SelectModelAsync(item.Name));
- UpdateHeader();
- }
-
- private async void OpenFolderButton_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(_owner));
-
- var folder = await picker.PickSingleFolderAsync();
- if (folder == null) return;
-
- _transcript.Append(_html.Info($"Project root changed to: {folder.Path}"));
- _transcript.Append(_html.Dim("Rebuilding the AI session for the new project…"));
-
- // Retargets THIS tab only — its own ProjectRootAccessor, file cache, and kernel.
- // Other agents keep working in their own folders.
- var session = Session;
- await Task.Run(async () =>
- {
- await session.ChangeProjectRootAsync(folder.Path);
- _transcript.Append(_html.Success("✓ Ready."));
- });
- UpdateHeader();
- if (_explorerOpen) BuildExplorerRoot(); // the open tree must follow the new root
- }
-
- // ============================================================
- // File explorer panel
- // ============================================================
-
- private bool _explorerOpen;
- private string? _explorerRoot; // root the tree was last built for
-
- private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen);
- private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false);
-
- private void ExplorerRefresh_Click(object sender, RoutedEventArgs e)
- {
- BuildExplorerRoot();
- RefreshBranchChip(force: true); // the Changes tab re-reads too
- }
-
- // --- Files / Changes tabs ---
-
- private bool _changesTabActive;
-
- private void FilesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: false);
- private void ChangesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: true);
-
- private void SetExplorerTab(bool changes)
- {
- _changesTabActive = changes;
- ExplorerTree.Visibility = changes ? Visibility.Collapsed : Visibility.Visible;
- ChangesList.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
- ChangesEmptyText.Visibility = changes && _changes.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- ChangesFooter.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
- CommitButton.IsEnabled = _changes.Count > 0;
- FilesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.Normal : Microsoft.UI.Text.FontWeights.SemiBold;
- ChangesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
- FilesTabButton.Opacity = changes ? 0.55 : 1;
- ChangesTabButton.Opacity = changes ? 1 : 0.55;
- }
-
- private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e)
- {
- if (_explorerOpen) SizeExplorer();
- }
-
- private void SizeExplorer()
- {
- // Default ~20% of the window, clamped so the tree stays usable on small windows and
- // doesn't waste half a 4K monitor on the other end. Once the user has dragged the
- // splitter, their width wins (re-clamped so a shrunken window can't strand the panel).
- var w = ChatRoot.ActualWidth;
- if (w <= 0) return;
- var target = _explorerUserWidth ?? Math.Clamp(w * 0.20, 220, 460);
- ExplorerPanel.Width = Math.Clamp(target, MinExplorerWidth, MaxExplorerWidth());
- }
-
- private const double MinExplorerWidth = 180;
- private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, ChatRoot.ActualWidth * 0.6);
-
- // --- splitter drag (same pointer-capture pattern as MainWindow's terminal splitter) ---
-
- private double? _explorerUserWidth; // set on first drag; SizeExplorer defers to it
- private bool _draggingExplorer;
- private double _explorerDragStartWidth;
- private double _explorerDragStartX;
-
- private void ExplorerSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
- {
- _draggingExplorer = true;
- _explorerDragStartWidth = ExplorerPanel.ActualWidth;
- _explorerDragStartX = e.GetCurrentPoint(ChatRoot).Position.X; // stable frame while the grip moves
- ((UIElement)sender).CapturePointer(e.Pointer);
- }
-
- private void ExplorerSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingExplorer) return;
- // Dragging left grows the panel; right shrinks it.
- var delta = e.GetCurrentPoint(ChatRoot).Position.X - _explorerDragStartX;
- var next = Math.Clamp(_explorerDragStartWidth - delta, MinExplorerWidth, MaxExplorerWidth());
- ExplorerPanel.Width = next;
- _explorerUserWidth = next;
- }
-
- private void ExplorerSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingExplorer) return;
- _draggingExplorer = false;
- ((UIElement)sender).ReleasePointerCapture(e.Pointer);
- }
-
- private void ToggleExplorer(bool open)
- {
- if (open == _explorerOpen) return;
- _explorerOpen = open;
-
- // Docked, not overlaid: the panel sits in the transcript row's second column, so
- // showing it RESIZES the transcript (text stays fully readable) and collapsing it
- // gives the width back. No slide animation — animating a WebView2's width forces
- // continuous relayout of the browser surface, and instant dock/undock is how
- // solution-explorer-style panels behave anyway.
- if (open)
- {
- SizeExplorer();
- // (Re)build on open when the tab's root changed since the tree was built — the
- // panel keeps its expansion state across close/open within the same root.
- if (_explorerRoot != _controller.ProjectRootPath) BuildExplorerRoot();
- ExplorerPanel.Visibility = Visibility.Visible;
- ExplorerSplitter.Visibility = Visibility.Visible;
- }
- else
- {
- ExplorerPanel.Visibility = Visibility.Collapsed;
- ExplorerSplitter.Visibility = Visibility.Collapsed;
- }
- }
-
- private void BuildExplorerRoot()
- {
- _explorerRoot = _controller.ProjectRootPath;
- ExplorerRootText.Text = Path.GetFileName(Path.TrimEndingDirectorySeparator(_explorerRoot));
- ToolTipService.SetToolTip(ExplorerRootText, _explorerRoot);
- ExplorerTree.RootNodes.Clear();
- foreach (var node in LoadChildNodes(_explorerRoot)) ExplorerTree.RootNodes.Add(node);
- StartExplorerWatcher(_explorerRoot);
- }
-
- // --- filesystem watcher: the tree follows external creates/deletes/renames on its own ---
- // Efficiency comes from three choices: (1) only NAME notifications — content writes don't
- // change tree shape; (2) events debounce into one flush, so a build touching 500 files
- // costs one pass; (3) a flush re-syncs only REALIZED directory nodes — churn under a
- // never-expanded folder (node_modules, bin/obj) is a hash lookup and a skip, because
- // lazy loading will read the truth from disk whenever it's finally expanded.
-
- private FileSystemWatcher? _fsWatcher;
- private readonly object _fsLock = new();
- private readonly HashSet _pendingFsDirs = new(StringComparer.OrdinalIgnoreCase);
- private bool _fsFlushQueued;
- private bool _fsSyncAll; // watcher buffer overflowed — re-sync every realized dir
-
- private void StartExplorerWatcher(string root)
- {
- StopExplorerWatcher();
- try
- {
- _fsWatcher = new FileSystemWatcher(root)
- {
- IncludeSubdirectories = true,
- // LastWrite so EDITS refresh git state (M rows, badges, dirty dot) — name
- // events alone only cover tree shape. Content writes are routed git-only
- // below: they can't change the tree, so they never trigger tree syncs.
- NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
- InternalBufferSize = 64 * 1024, // max — fewer overflows during big builds
- };
- _fsWatcher.Created += (_, e) => QueueFsEvent(e.FullPath);
- _fsWatcher.Deleted += (_, e) => QueueFsEvent(e.FullPath);
- _fsWatcher.Renamed += (_, e) => { QueueFsEvent(e.OldFullPath); QueueFsEvent(e.FullPath); };
- _fsWatcher.Changed += (_, e) => QueueFsEvent(e.FullPath, treeRelevant: false);
- _fsWatcher.Error += (_, _) => { lock (_fsLock) { _fsSyncAll = true; } QueueFsEvent(root); };
- _fsWatcher.EnableRaisingEvents = true;
- }
- catch
- {
- _fsWatcher = null; // best-effort — the refresh button still exists
- }
- }
-
- private void StopExplorerWatcher()
- {
- try { _fsWatcher?.Dispose(); } catch { }
- _fsWatcher = null;
- }
-
- /// Threadpool-side: coalesce this event's parent directory into the pending set
- /// and arm one debounced flush. .git churn and content-only writes skip the tree but
- /// still refresh git state — that's how external edits, branch switches, and commits
- /// show up without a manual refresh.
- private void QueueFsEvent(string fullPath, bool treeRelevant = true)
- {
- bool arm;
- lock (_fsLock)
- {
- var rel = ToRelOrNull(fullPath)?.Replace('\\', '/');
- if (rel == null) return;
- var isGit = rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase);
-
- // Our OWN git calls write .git/index (+ transient *.lock files) — reacting to
- // those would refresh forever: refresh → git status → index event → refresh…
- // Ignore them; real external actions (checkout, commit) also touch HEAD/refs,
- // which still get through and trigger the refresh we want.
- if (isGit && (rel.EndsWith("/index", StringComparison.OrdinalIgnoreCase)
- || rel.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)))
- return;
-
- if (!isGit && treeRelevant)
- _pendingFsDirs.Add(Path.GetDirectoryName(fullPath) ?? "");
-
- // Workspace notes: remember WHICH files were touched while the agent was idle.
- // Status-snapshot diffing alone misses content edits to files that were ALREADY
- // dirty/untracked (their status entry doesn't change) — this set fills that gap.
- // Idle-gated so the agent's own writes never count as external.
- if (!isGit && !_controller.IsProcessing)
- _wsTracker.RecordTouch(rel);
-
- arm = !_fsFlushQueued;
- _fsFlushQueued = true;
- }
- if (arm) _ = FlushFsEventsAsync();
-
- string? ToRelOrNull(string p)
- {
- var root = _explorerRoot;
- if (root == null) return null;
- var prefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
- return p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? p[prefix.Length..] : null;
- }
- }
-
- private async Task FlushFsEventsAsync()
- {
- await Task.Delay(800); // coalesce the burst
- List dirs;
- bool syncAll;
- lock (_fsLock)
- {
- syncAll = _fsSyncAll;
- _fsSyncAll = false;
- dirs = _pendingFsDirs.ToList();
- _pendingFsDirs.Clear();
- _fsFlushQueued = false;
- }
- OnUi(() =>
- {
- if (_shutDown) return;
- if (syncAll) SyncAllRealizedDirs();
- else foreach (var dir in dirs) SyncRealizedDir(dir);
- RefreshBranchChip(force: true); // badges, Changes tab, and status strip follow
- });
- }
-
- /// Re-syncs one directory's children IF that directory is realized in the tree;
- /// unexpanded directories are skipped (lazy load reads fresh from disk anyway).
- private void SyncRealizedDir(string dir)
- {
- var list = FindRealizedChildList(dir);
- if (list != null) SyncDirectoryNode(list, dir);
- }
-
- private void SyncAllRealizedDirs()
- {
- var root = _explorerRoot;
- if (root == null) return;
- SyncDirectoryNode(ExplorerTree.RootNodes, root);
- Walk(ExplorerTree.RootNodes);
-
- void Walk(IList nodes)
- {
- foreach (var n in nodes)
- {
- if (n is { HasUnrealizedChildren: false, Content: ExplorerItem { IsDirectory: true } item })
- {
- SyncDirectoryNode(n.Children, item.FullPath);
- Walk(n.Children);
- }
- }
- }
- }
-
- private IList? FindRealizedChildList(string dir)
- {
- var root = _explorerRoot;
- if (root == null) return null;
- if (PathsEqual(dir, root)) return ExplorerTree.RootNodes;
- return Find(ExplorerTree.RootNodes);
-
- IList? Find(IList nodes)
- {
- foreach (var n in nodes)
- {
- if (n.Content is ExplorerItem { IsDirectory: true } item && PathsEqual(item.FullPath, dir))
- return n.HasUnrealizedChildren ? null : n.Children;
- if (n.Children.Count > 0)
- {
- var found = Find(n.Children);
- if (found != null) return found;
- }
- }
- return null;
- }
-
- static bool PathsEqual(string a, string b) => string.Equals(
- Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b),
- StringComparison.OrdinalIgnoreCase);
- }
-
- /// Minimal diff of a realized directory node against disk: remove rows whose
- /// path vanished, insert new rows at their sorted position. Never rebuilds surviving
- /// nodes, so expansion state below them is preserved.
- private void SyncDirectoryNode(IList children, string dir)
- {
- var root = _explorerRoot ?? _controller.ProjectRootPath;
- string[] dirs, files;
- try
- {
- dirs = Directory.GetDirectories(dir);
- files = Directory.GetFiles(dir);
- }
- catch (Exception) { return; }
- Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
- Array.Sort(files, StringComparer.OrdinalIgnoreCase);
-
- var desired = new List<(string Path, bool IsDir)>(dirs.Length + files.Length);
- foreach (var d in dirs) desired.Add((d, true));
- foreach (var f in files) desired.Add((f, false));
- var desiredSet = new HashSet(desired.Select(x => x.Path), StringComparer.OrdinalIgnoreCase);
-
- for (var i = children.Count - 1; i >= 0; i--)
- if (children[i].Content is ExplorerItem it && !desiredSet.Contains(it.FullPath))
- children.RemoveAt(i);
-
- var existing = new HashSet(
- children.Select(n => (n.Content as ExplorerItem)?.FullPath ?? ""),
- StringComparer.OrdinalIgnoreCase);
-
- for (var idx = 0; idx < desired.Count; idx++)
- {
- var (path, isDir) = desired[idx];
- if (existing.Contains(path)) continue;
- var item = isDir ? ExplorerItem.ForFolder(path, root) : ExplorerItem.ForFile(path, root);
- item.Dirty = IsItemDirty(item);
- var node = new TreeViewNode { Content = item };
- if (isDir) node.HasUnrealizedChildren = true;
- children.Insert(Math.Min(idx, children.Count), node);
- }
- }
-
- /// One directory level, folders first then files, both alphabetical. Unreadable
- /// or vanished directories render as empty rather than throwing.
- private List LoadChildNodes(string dir)
- {
- var root = _explorerRoot ?? _controller.ProjectRootPath;
- var nodes = new List();
- string[] dirs, files;
- try
- {
- dirs = Directory.GetDirectories(dir);
- files = Directory.GetFiles(dir);
- }
- catch (Exception) { return nodes; }
- Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
- Array.Sort(files, StringComparer.OrdinalIgnoreCase);
- foreach (var d in dirs)
- {
- var item = ExplorerItem.ForFolder(d, root);
- item.Dirty = IsItemDirty(item);
- nodes.Add(new TreeViewNode { Content = item, HasUnrealizedChildren = true });
- }
- foreach (var f in files)
- {
- var item = ExplorerItem.ForFile(f, root);
- item.Dirty = IsItemDirty(item);
- nodes.Add(new TreeViewNode { Content = item });
- }
- return nodes;
- }
-
- /// The row's @ button — shared by the file tree (TreeViewNode rows) and the
- /// Changes list (GitChangeItem rows): tags the file/folder in the prompt, identical
- /// result to dragging the row onto the input box.
- private void ExplorerTag_Click(object sender, RoutedEventArgs e)
- {
- var ctx = (sender as FrameworkElement)?.DataContext;
- var path = ctx switch
- {
- TreeViewNode { Content: ExplorerItem item } => item.FullPath,
- GitChangeItem change => change.FullPath,
- _ => null,
- };
- if (path != null) InsertFileTokens(new[] { path });
- }
-
- private void ChangesList_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
- {
- var paths = e.Items.OfType().Select(c => c.FullPath).ToList();
- if (paths.Count == 0) { e.Cancel = true; return; }
- e.Data.SetText(string.Join("\n", paths));
- e.Data.RequestedOperation = DataPackageOperation.Copy;
- }
-
- /// The row's ± button: show this file's diff as a transcript DiffCard. An
- /// explicit button (not row click) so selecting or starting a drag never spawns a card,
- /// and no click-vs-double-click disambiguation delay is needed.
- private async void ChangesDiff_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.DataContext is not GitChangeItem item || _shutDown) return;
-
- var root = _controller.ProjectRootPath;
- var diff = await Task.Run(() => GitQuickStatus.TryGetDiff(root, item.RelPath, untracked: item.Kind == "U"));
- if (_shutDown) return;
-
- if (diff == null)
- _transcript.Append(_html.Warn($"Couldn't get a diff for {item.RelPath}"));
- else if (diff.Lines.Count == 0)
- _transcript.Append(_html.Dim($"{item.RelPath}: {diff.Summary}"));
- else
- _transcript.Append(_html.DiffCard(item.RelPath, diff.Lines, diff.Summary, interactive: true));
- }
-
- /// Pre-fills the prompt with a commit request — never sends, never commits.
- /// Caret-aware insert, so tagging files first then clicking Commit… composes naturally
- /// ("@a.cs @b.cs Commit the current changes…"). The user can edit, then sends; the
- /// bottom-bar approval gates the actual git command.
- private void Commit_Click(object sender, RoutedEventArgs e) =>
- InsertAtCaret("Commit the current changes with an appropriate message");
-
- private void ChangeUndo_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.DataContext is GitChangeItem item)
- UndoFileFromCard(item.RelPath);
- }
-
- /// Fire-and-forget bridge for non-async call sites (web message handler, row
- /// button). async void is safe here: ConfirmAndUndoAsync catches nothing fatal — git
- /// failure is reported to the transcript, not thrown.
- private async void UndoFileFromCard(string relPath) => await ConfirmAndUndoAsync(relPath);
-
- /// The one destructive action in the app, so it always confirms first —
- /// whether it came from a Changes row or a diff card's Undo chip.
- private async Task ConfirmAndUndoAsync(string relPath)
- {
- var dialog = new ContentDialog
- {
- Title = "Discard changes?",
- Content = $"{relPath} will be restored to its state at the last commit. This can't be undone.",
- PrimaryButtonText = "Discard changes",
- CloseButtonText = "Cancel",
- DefaultButton = ContentDialogButton.Close,
- XamlRoot = XamlRoot,
- };
- if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
- var root = _controller.ProjectRootPath;
- var ok = await Task.Run(() => GitQuickStatus.TryUndoChanges(root, relPath));
- if (_shutDown) return;
- _transcript.Append(ok
- ? _html.Success($"Restored {relPath} to its state at the last commit.")
- : _html.Warn($"Couldn't restore {relPath} — is it still tracked by git?"));
- if (ok)
- {
- // Tell the model explicitly — discarding its work is feedback, not just a file
- // event — and re-baseline so the generic delta doesn't report it a second time.
- _controller.NoteWorkspaceEvent(
- $"The user DISCARDED all uncommitted changes to {relPath} (restored to the last commit). " +
- "If you changed that file earlier, those changes are gone by the user's choice — don't re-apply them unless asked.");
- _wsTracker.MarkCapturePending();
- }
- RefreshBranchChip(force: true);
- }
-
- private void ChangesList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
- {
- if ((e.OriginalSource as FrameworkElement)?.DataContext is not GitChangeItem item) return;
- if (!File.Exists(item.FullPath)) return; // deleted entries have nothing to open
- try
- {
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
- {
- FileName = item.FullPath,
- UseShellExecute = true,
- });
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
- }
- }
-
- private void ExplorerTag_PointerEntered(object sender, PointerRoutedEventArgs e)
- => ((UIElement)sender).Opacity = 1;
-
- private void ExplorerTag_PointerExited(object sender, PointerRoutedEventArgs e)
- => ((UIElement)sender).Opacity = 0.45;
-
- private void ExplorerTree_Expanding(TreeView sender, TreeViewExpandingEventArgs args)
- {
- if (!args.Node.HasUnrealizedChildren) return;
- args.Node.HasUnrealizedChildren = false;
- if (args.Node.Content is not ExplorerItem item || !item.IsDirectory) return;
- foreach (var child in LoadChildNodes(item.FullPath)) args.Node.Children.Add(child);
- }
-
- private void ExplorerTree_ItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
- {
- // Single click: folders toggle, files only select. Opening is double-click territory
- // (ExplorerTree_DoubleTapped) — a stray single click must never launch an app.
- if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: true } } node)
- node.IsExpanded = !node.IsExpanded;
- }
-
- private void ExplorerTree_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
- {
- // The template's elements inherit the row's TreeViewNode as DataContext.
- if ((e.OriginalSource as FrameworkElement)?.DataContext is not TreeViewNode node ||
- node.Content is not ExplorerItem { IsDirectory: false } item)
- return;
- try
- {
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
- {
- FileName = item.FullPath,
- UseShellExecute = true,
- });
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
- }
- }
-
- // ============================================================
- // Drag & drop @-references
- // ============================================================
-
- /// Dragging explorer rows carries their full paths as text — the input box's
- /// Drop handler recognizes existing paths and converts them to @tokens.
- private void ExplorerTree_DragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
- {
- var paths = args.Items.OfType()
- .Select(n => n.Content).OfType()
- .Select(i => i.FullPath).ToList();
- if (paths.Count == 0) { args.Cancel = true; return; }
- args.Data.SetText(string.Join("\n", paths));
- args.Data.RequestedOperation = DataPackageOperation.Copy;
- }
-
- private void InputBox_DragOver(object sender, DragEventArgs e)
- {
- if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
- e.DataView.Contains(StandardDataFormats.Text))
- {
- e.AcceptedOperation = DataPackageOperation.Copy;
- e.Handled = true;
- }
- }
-
- // --- drop-to-tag overlay choreography ---
- // Show when a drag enters the tab: over XAML chrome that's ChatRoot's DragEnter; over the
- // WebView it's the transcript script's 'drag-enter' message (Chromium owns drags there).
- // Hide when the drag leaves the overlay/tab or when any drop completes. Moving between
- // those regions can flicker the overlay off/on for a frame — harmless.
-
- private void ShowDropOverlay() => DropOverlay.Visibility = Visibility.Visible;
- private void HideDropOverlay() => DropOverlay.Visibility = Visibility.Collapsed;
-
- private void ChatRoot_DragEnter(object sender, DragEventArgs e)
- {
- if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
- e.DataView.Contains(StandardDataFormats.Text))
- ShowDropOverlay();
- }
-
- private void ChatRoot_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
- private void DropOverlay_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
-
- private void DropOverlay_DragOver(object sender, DragEventArgs e)
- {
- if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
- e.DataView.Contains(StandardDataFormats.Text))
- {
- e.AcceptedOperation = DataPackageOperation.Copy;
- e.Handled = true;
- }
- }
-
- private async void DropOverlay_Drop(object sender, DragEventArgs e)
- {
- HideDropOverlay();
- await HandleDropAsync(e);
- }
-
- private async void InputBox_Drop(object sender, DragEventArgs e)
- {
- HideDropOverlay();
- await HandleDropAsync(e);
- }
-
- /// Shared drop handling for the input box and the drop-to-tag overlay: paths
- /// become @tokens, ordinary text inserts as text.
- private async Task HandleDropAsync(DragEventArgs e)
- {
- e.Handled = true;
- var deferral = e.GetDeferral();
- try
- {
- if (e.DataView.Contains(StandardDataFormats.StorageItems))
- {
- // Shell drop (Windows Explorer): real files/folders with paths.
- var items = await e.DataView.GetStorageItemsAsync();
- InsertFileTokens(items.Select(i => i.Path).Where(p => !string.IsNullOrEmpty(p)));
- }
- else if (e.DataView.Contains(StandardDataFormats.Text))
- {
- // Text drop: explorer-tree rows arrive as newline-joined full paths. If every
- // line is an existing path, tokenize; otherwise it's ordinary dragged text.
- var text = await e.DataView.GetTextAsync();
- var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- if (lines.Length > 0 && lines.All(l => File.Exists(l) || Directory.Exists(l)))
- InsertFileTokens(lines);
- else
- InsertAtCaret(text);
- }
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Warn($"Couldn't read the dropped item: {ex.Message}"));
- }
- finally
- {
- deferral.Complete();
- }
- }
-
- /// Converts full paths into the same @tokens the autocomplete inserts: project-root
- /// relative, forward slashes, trailing '/' for folders. Items outside this tab's project
- /// root can't be resolved by the @ pipeline, so they're skipped with a warning.
- private void InsertFileTokens(IEnumerable fullPaths)
- {
- var root = _controller.ProjectRootPath;
- var rootPrefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
- var tokens = new List();
- var outside = new List();
-
- foreach (var raw in fullPaths)
- {
- string full;
- try { full = Path.GetFullPath(raw); }
- catch { continue; }
- if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
- {
- outside.Add(full);
- continue;
- }
- var rel = Path.GetRelativePath(root, full).Replace('\\', '/');
- tokens.Add("@" + rel + (Directory.Exists(full) ? "/" : ""));
- }
-
- if (tokens.Count > 0)
- InsertAtCaret(string.Join(" ", tokens) + " ");
- if (outside.Count > 0)
- _transcript.Append(_html.Warn(
- $"Skipped {outside.Count} dropped item{(outside.Count == 1 ? "" : "s")} outside this tab's project folder — @ references only work under {root}"));
- }
-
- /// Inserts at the caret with token-safe spacing: a separating space is added when
- /// the caret touches non-whitespace, so a dropped @token never glues onto existing text.
- private void InsertAtCaret(string insert)
- {
- var text = InputBox.Text;
- var caret = Math.Clamp(InputBox.SelectionStart, 0, text.Length);
- if (caret > 0 && !char.IsWhiteSpace(text[caret - 1])) insert = " " + insert;
- InputBox.Text = text[..caret] + insert + text[caret..];
- InputBox.SelectionStart = caret + insert.Length;
- InputBox.Focus(FocusState.Programmatic);
- }
-
- // ============================================================
- // Input handling
- // ============================================================
-
- private void SendButton_Click(object sender, RoutedEventArgs e)
- {
- if (_controller.IsProcessing)
- {
- _controller.CancelActiveRequest();
- return;
- }
- SubmitCurrentInput();
- }
-
- private void SubmitCurrentInput()
- {
- var text = InputBox.Text;
- if (string.IsNullOrWhiteSpace(text) || _controller.IsProcessing) return;
-
- EmitWorkspaceDelta(); // queue outside-the-conversation changes before this send
- InputBox.Text = "";
- HideSuggestions();
- UpdateHeader();
-
- _ = Task.Run(async () =>
- {
- try
- {
- await _controller.SubmitAsync(text);
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Error($"Unexpected error: {ex.Message}"));
- }
- });
- }
-
- // PreviewKeyDown, NOT KeyDown: the TextBox's own class handler runs before instance
- // KeyDown handlers, so with AcceptsReturn=true an Enter had already inserted a newline
- // — which made TextChanged hide the suggestions popup, and the handler then fell
- // through to submit. Preview (tunneling) fires first, so Handled=true genuinely
- // suppresses the newline and Enter-to-accept behaves exactly like a mouse click.
- private void InputBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
- {
- if (e.Key == VirtualKey.Enter)
- {
- var shift = Microsoft.UI.Input.InputKeyboardSource
- .GetKeyStateForCurrentThread(VirtualKey.Shift)
- .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
- if (!shift)
- {
- e.Handled = true;
-
- // If suggestions are open, Enter accepts (falling back to the first row —
- // never submit the half-typed token as a message).
- if (SuggestionsPanel.Visibility == Visibility.Visible)
- {
- var pick = SuggestionsList.SelectedItem as CommandSuggestion ?? _suggestions.FirstOrDefault();
- if (pick != null)
- {
- AcceptSuggestion(pick);
- return;
- }
- }
- SubmitCurrentInput();
- }
- }
- else if (e.Key == VirtualKey.Tab && SuggestionsPanel.Visibility == Visibility.Visible)
- {
- var pick = (SuggestionsList.SelectedItem ?? _suggestions.FirstOrDefault()) as CommandSuggestion;
- if (pick != null)
- {
- e.Handled = true;
- AcceptSuggestion(pick);
- }
- }
- else if (e.Key == VirtualKey.Down && SuggestionsPanel.Visibility == Visibility.Visible)
- {
- e.Handled = true;
- SuggestionsList.SelectedIndex = Math.Min(SuggestionsList.SelectedIndex + 1, _suggestions.Count - 1);
- SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
- }
- else if (e.Key == VirtualKey.Up && SuggestionsPanel.Visibility == Visibility.Visible)
- {
- e.Handled = true;
- SuggestionsList.SelectedIndex = Math.Max(SuggestionsList.SelectedIndex - 1, 0);
- SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
- }
- else if (e.Key == VirtualKey.Escape)
- {
- if (SuggestionsPanel.Visibility == Visibility.Visible) HideSuggestions();
- else _controller.CancelActiveRequest();
- }
- }
-
- private void InputBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSuggestions();
-
- private void UpdateSuggestions()
- {
- var text = InputBox.Text;
- var caret = InputBox.SelectionStart;
-
- // Slash commands: input starts with '/' and is still a single token.
- if (text.StartsWith('/') && !text.Contains(' '))
- {
- var matches = _controller.GetCommandSuggestions(text);
- if (ShowSuggestions(SuggestMode.Command, 0, caret,
- matches.Select(m => new CommandSuggestion { Command = m.Command, Description = m.Description })))
- return;
- }
-
- // @file references: find the token containing the caret; if it starts with '@',
- // filter project files/directories through the same provider the CLI uses
- // (directories come back with a trailing '/' — selecting one drills into it).
- var tokenStart = caret;
- while (tokenStart > 0 && !char.IsWhiteSpace(text[tokenStart - 1]))
- tokenStart--;
-
- if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == '@')
- {
- var fragment = text[(tokenStart + 1)..caret];
- List matches;
- try { matches = _fileProvider.FilterFiles(fragment); }
- catch { matches = new List(); }
-
- if (ShowSuggestions(SuggestMode.File, tokenStart, caret,
- matches.Select(m => new CommandSuggestion
- {
- Command = m,
- Description = m.EndsWith('/') ? "folder — select to drill in" : "file"
- })))
- return;
- }
-
- // :emoji: shortcodes (Slack-style). Two behaviors on the token containing the caret:
- // - ":name:" fully typed with an exact match → replace it with the emoji right here.
- // - ":fra" partially typed (2+ chars, no closing ':') → suggest matching shortcodes.
- // The 2-char minimum keeps ordinary colons (":)", "note:") from popping the list.
- if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == ':')
- {
- var body = text[(tokenStart + 1)..caret];
- if (body.Length > 1 && body.EndsWith(':'))
- {
- var name = body[..^1].ToLowerInvariant();
- var exact = EmojiShortcodes.FirstOrDefault(s => s.Name == name).Emoji;
- if (exact != null)
- {
- InputBox.Text = text[..tokenStart] + exact + text[caret..];
- InputBox.SelectionStart = tokenStart + exact.Length;
- HideSuggestions();
- return;
- }
- }
- else if (body.Length >= 2 && !body.Contains(':'))
- {
- var frag = body.ToLowerInvariant();
- var matches = EmojiShortcodes.Where(s => s.Name.StartsWith(frag))
- .Concat(EmojiShortcodes.Where(s => !s.Name.StartsWith(frag) && s.Name.Contains(frag)));
-
- if (ShowSuggestions(SuggestMode.Emoji, tokenStart, caret,
- matches.Select(m => new CommandSuggestion
- {
- Command = ":" + m.Name + ":",
- Description = m.Emoji,
- InsertText = m.Emoji,
- })))
- return;
- }
- }
-
- HideSuggestions();
- }
-
- private bool ShowSuggestions(SuggestMode mode, int tokenStart, int tokenEnd, IEnumerable items)
- {
- _suggestions.Clear();
- foreach (var item in items) _suggestions.Add(item);
- if (_suggestions.Count == 0) return false;
-
- _suggestMode = mode;
- _tokenStart = tokenStart;
- _tokenEnd = tokenEnd;
- SuggestionsPanel.Visibility = Visibility.Visible;
- SuggestionsList.SelectedIndex = 0;
- SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
- return true;
- }
-
- private void SuggestionsList_ItemClick(object sender, ItemClickEventArgs e)
- {
- if (e.ClickedItem is CommandSuggestion s) AcceptSuggestion(s);
- }
-
- private void AcceptSuggestion(CommandSuggestion s)
- {
- if (_suggestMode == SuggestMode.File)
- {
- var text = InputBox.Text;
- var start = Math.Min(_tokenStart, text.Length);
- var end = Math.Min(_tokenEnd, text.Length);
-
- // Replace the @token with the picked path. Directories keep the caret hot
- // (no trailing space) so the reopened popup shows their contents; files
- // close the token with a space.
- var isFolder = s.Command.EndsWith('/');
- var replacement = "@" + s.Command + (isFolder ? "" : " ");
- InputBox.Text = text[..start] + replacement + text[end..];
- InputBox.SelectionStart = start + replacement.Length;
-
- // Setting .Text resets the caret to 0 BEFORE the line above restores it, and
- // TextChanged runs in that window — it sees no token at caret 0 and hides the
- // popup. Recompute now that the caret is where the user expects it:
- // folder → drilled listing reopens; file → token ended with a space, stays hidden.
- UpdateSuggestions();
- }
- else if (_suggestMode == SuggestMode.Emoji)
- {
- var text = InputBox.Text;
- var start = Math.Min(_tokenStart, text.Length);
- var end = Math.Min(_tokenEnd, text.Length);
- var emoji = s.InsertText ?? s.Command;
- InputBox.Text = text[..start] + emoji + text[end..];
- InputBox.SelectionStart = start + emoji.Length;
- HideSuggestions();
- }
- else
- {
- InputBox.Text = s.Command + " ";
- InputBox.SelectionStart = InputBox.Text.Length;
- HideSuggestions();
- }
- InputBox.Focus(FocusState.Programmatic);
- }
-
- /// Curated quick-pick set for the emoji flyout; Win + . remains the full picker.
- private static readonly string[] QuickEmojis =
- {
- "😀", "😄", "😂", "🤣", "😊", "😉", "😍", "🥰", "😎", "🤓", "🤔", "🙃",
- "😅", "😬", "😭", "🥳", "🤯", "😴", "🙄", "😤", "😱", "🫠", "🤗", "🫡",
- "👍", "👎", "👌", "🙏", "👏", "💪", "🤝", "✌️", "🤞", "👀", "🧠", "💯",
- "🔥", "✨", "🚀", "🎉", "🎯", "💡", "⚡", "⭐", "❤️", "💔", "✅", "❌",
- "⚠️", "❓", "❗", "💬", "🐛", "🔧", "🔒", "🔑", "📝", "📌", "📁", "🖥️",
- "☕", "🍕", "🎮", "🤖",
- };
-
- /// Slack-style shortcode → emoji. Aliases are separate rows pointing at the same
- /// emoji. Names must be lowercase; lookup lowercases the typed fragment.
- private static readonly (string Name, string Emoji)[] EmojiShortcodes =
- {
- ("grinning", "😀"), ("smile", "😄"), ("joy", "😂"), ("rofl", "🤣"),
- ("blush", "😊"), ("wink", "😉"), ("heart_eyes", "😍"), ("smiling_hearts", "🥰"),
- ("sunglasses", "😎"), ("coolglasses", "😎"), ("nerd", "🤓"), ("thinking", "🤔"),
- ("upside_down", "🙃"), ("sweat_smile", "😅"), ("grimacing", "😬"), ("sob", "😭"),
- ("partying", "🥳"), ("mind_blown", "🤯"), ("sleeping", "😴"), ("eye_roll", "🙄"),
- ("triumph", "😤"), ("scream", "😱"), ("melting", "🫠"), ("hugs", "🤗"),
- ("salute", "🫡"), ("thumbsup", "👍"), ("+1", "👍"), ("thumbsdown", "👎"),
- ("-1", "👎"), ("ok_hand", "👌"), ("pray", "🙏"), ("clap", "👏"),
- ("muscle", "💪"), ("handshake", "🤝"), ("victory", "✌️"), ("crossed_fingers", "🤞"),
- ("eyes", "👀"), ("brain", "🧠"), ("100", "💯"), ("fire", "🔥"),
- ("sparkles", "✨"), ("rocket", "🚀"), ("tada", "🎉"), ("party_popper", "🎉"),
- ("dart", "🎯"), ("bulb", "💡"), ("idea", "💡"), ("zap", "⚡"),
- ("star", "⭐"), ("heart", "❤️"), ("broken_heart", "💔"), ("check", "✅"),
- ("white_check_mark", "✅"), ("x", "❌"), ("cross", "❌"), ("warning", "⚠️"),
- ("question", "❓"), ("exclamation", "❗"), ("speech_balloon", "💬"), ("bug", "🐛"),
- ("wrench", "🔧"), ("lock", "🔒"), ("key", "🔑"), ("memo", "📝"),
- ("note", "📝"), ("pushpin", "📌"), ("pin", "📌"), ("folder", "📁"),
- ("desktop", "🖥️"), ("coffee", "☕"), ("pizza", "🍕"), ("video_game", "🎮"),
- ("robot", "🤖"),
- };
-
- private void EmojiGrid_ItemClick(object sender, ItemClickEventArgs e)
- {
- if (e.ClickedItem is not string emoji || !InputBox.IsEnabled) return;
- var caret = Math.Min(InputBox.SelectionStart, InputBox.Text.Length);
- InputBox.Text = InputBox.Text.Insert(caret, emoji);
- InputBox.SelectionStart = caret + emoji.Length;
- InputBox.Focus(FocusState.Programmatic);
- }
-
- private void HideSuggestions()
- {
- _suggestMode = SuggestMode.None;
- SuggestionsPanel.Visibility = Visibility.Collapsed;
- _suggestions.Clear();
- }
-
- // ============================================================
- // IApprovalUi — this tab's approval overlay (completes the harness's awaited TCS)
- // ============================================================
-
- public Task ShowApprovalAsync(ApprovalRequest request, CancellationToken ct = default)
- {
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _approvalTcs = tcs;
-
- var reg = ct.CanBeCanceled
- ? ct.Register(() =>
- {
- tcs.TrySetCanceled(ct);
- OnUi(() => { HideApprovalOverlay(); HidePlanApprovalBar(); });
- })
- : default(CancellationTokenRegistration);
-
- OnUi(() =>
- {
- // What the cross-tab toast will say — a specific "what's waiting" line, not the modal's
- // question. Set for both the bottom-bar and modal paths.
- _approvalSummary = string.IsNullOrEmpty(request.ToastSummary) ? request.Title : request.ToastSummary;
-
- // Plan approvals render as a non-covering bottom bar so the plan card stays readable.
- if (request.BottomBar)
- {
- ShowPlanApprovalBar(request, choice =>
- {
- HidePlanApprovalBar();
- reg.Dispose();
- tcs.TrySetResult(choice);
- if (ReferenceEquals(_approvalTcs, tcs)) _approvalTcs = null;
- });
- return;
- }
-
- ApprovalTitle.Text = request.Title;
-
- ApprovalSubtitle.Text = request.Subtitle ?? "";
- ApprovalSubtitle.Visibility = string.IsNullOrEmpty(request.Subtitle) ? Visibility.Collapsed : Visibility.Visible;
-
- ApprovalDetail.Text = request.Detail ?? "";
- ApprovalDetail.Visibility = string.IsNullOrEmpty(request.Detail) ? Visibility.Collapsed : Visibility.Visible;
-
- // Pull the shared, theme-mutated brushes from app resources so the approval diff
- // follows the active theme (these used to be hardcoded LightSkyBlue/red/gray, which
- // stayed blue under every theme — jarring under E-Ink). Mirrors the transcript's
- // diff coloring: command/added -> sky, removed -> red, context -> dim.
- var skyBrush = (SolidColorBrush)Application.Current.Resources["MandoSkyBrush"];
- var redBrush = (SolidColorBrush)Application.Current.Resources["MandoRedBrush"];
- var dimBrush = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
-
- var rows = new List();
- if (request.CommandText != null)
- {
- rows.Add(new DiffLineVm
- {
- Text = $"$ {request.CommandText}",
- Brush = skyBrush
- });
- }
- if (request.DiffLines != null)
- {
- foreach (var line in request.DiffLines)
- {
- var (prefix, brush) = line.LineType switch
- {
- DiffLineType.Added => ("+ ", skyBrush),
- DiffLineType.Removed => ("- ", redBrush),
- _ => (" ", dimBrush)
- };
- var num = (line.LineType == DiffLineType.Added ? line.NewLineNumber : line.OldLineNumber);
- rows.Add(new DiffLineVm
- {
- Text = $"{(num.HasValue ? num.Value.ToString().PadLeft(4) : " ")} {prefix}{line.Content}",
- Brush = brush
- });
- }
- if (request.DiffSummary != null)
- rows.Add(new DiffLineVm { Text = "", Brush = dimBrush });
- }
- ApprovalDiffList.ItemsSource = rows;
- ApprovalBodyScroll.Visibility = rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
-
- if (!string.IsNullOrEmpty(request.DiffSummary))
- {
- ApprovalDetail.Text = request.DiffSummary;
- ApprovalDetail.Visibility = Visibility.Visible;
- }
-
- ApprovalButtons.Children.Clear();
- foreach (var option in request.Options)
- {
- var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
- if (!string.IsNullOrEmpty(option.Glyph))
- content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
- content.Children.Add(new TextBlock { Text = option.Label });
- var button = new Button
- {
- Content = content,
- HorizontalAlignment = HorizontalAlignment.Stretch,
- HorizontalContentAlignment = HorizontalAlignment.Left,
- Tag = option.Label
- };
- button.Foreground = option.Kind switch
- {
- ApprovalOptionKind.Proceed => (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"],
- ApprovalOptionKind.Destructive => (SolidColorBrush)Application.Current.Resources["MandoRedBrush"],
- _ => (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
- };
- if (!string.IsNullOrEmpty(option.Description))
- ToolTipService.SetToolTip(button, option.Description);
- button.Click += (_, _) =>
- {
- var choice = (string)button.Tag;
- HideApprovalOverlay();
- reg.Dispose();
- _approvalTcs?.TrySetResult(choice);
- _approvalTcs = null;
- };
- ApprovalButtons.Children.Add(button);
- }
-
- InstructionPanel.Visibility = Visibility.Collapsed;
- ApprovalButtons.Visibility = Visibility.Visible;
- SetApprovalCardSize(instructionMode: false);
- ShowApprovalOverlay();
- });
-
- return tcs.Task;
- }
-
- /// Approval mode: compact centered card. Instruction mode: full width and
- /// half the window height, centered — room to write real instructions.
- private void SetApprovalCardSize(bool instructionMode)
- {
- if (instructionMode)
- {
- ApprovalCard.HorizontalAlignment = HorizontalAlignment.Stretch;
- ApprovalCard.MaxWidth = double.PositiveInfinity;
- ApprovalCard.MaxHeight = double.PositiveInfinity;
- ApprovalCard.Height = Math.Max(320, ChatRoot.ActualHeight * 0.5);
- }
- else
- {
- ApprovalCard.HorizontalAlignment = HorizontalAlignment.Center;
- ApprovalCard.MaxWidth = 860;
- ApprovalCard.MaxHeight = 640;
- ApprovalCard.Height = double.NaN;
- }
- }
-
- public Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default)
- {
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _instructionTcs = tcs;
-
- OnUi(() =>
- {
- // First line is the question; any extra lines (e.g. a validation error on
- // re-prompt) render below it in the smaller prompt text.
- var newline = prompt.IndexOf('\n');
- ApprovalTitle.Text = newline < 0 ? prompt : prompt[..newline];
- InstructionPrompt.Text = newline < 0 ? "" : prompt[(newline + 1)..].Trim();
- InstructionPrompt.Visibility = InstructionPrompt.Text.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
-
- ApprovalSubtitle.Visibility = Visibility.Collapsed;
- ApprovalDetail.Visibility = Visibility.Collapsed;
- ApprovalBodyScroll.Visibility = Visibility.Collapsed;
- ApprovalButtons.Visibility = Visibility.Collapsed;
-
- InstructionBox.Text = "";
- InstructionBox.PlaceholderText = string.IsNullOrEmpty(placeholder)
- ? "Type your answer and press Enter"
- : placeholder;
- InstructionCancelButton.Visibility = allowCancel ? Visibility.Visible : Visibility.Collapsed;
- InstructionPanel.Visibility = Visibility.Visible;
- SetApprovalCardSize(instructionMode: true);
- ShowApprovalOverlay();
- InstructionBox.Focus(FocusState.Programmatic);
- });
-
- return tcs.Task;
- }
-
- private void InstructionBox_KeyDown(object sender, KeyRoutedEventArgs e)
- {
- if (e.Key == VirtualKey.Enter)
- {
- // Shift+Enter inserts a newline (the box is multi-line); plain Enter submits.
- // PreviewKeyDown is required here — with AcceptsReturn, the class handler
- // would insert the newline before a plain KeyDown handler ever ran.
- var shift = Microsoft.UI.Input.InputKeyboardSource
- .GetKeyStateForCurrentThread(VirtualKey.Shift)
- .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
- if (shift) return;
- e.Handled = true;
- SubmitInstruction();
- }
- else if (e.Key == VirtualKey.Escape && InstructionCancelButton.Visibility == Visibility.Visible)
- {
- e.Handled = true;
- CancelInstruction();
- }
- }
-
- private void InstructionSubmit_Click(object sender, RoutedEventArgs e) => SubmitInstruction();
-
- private void InstructionCancel_Click(object sender, RoutedEventArgs e) => CancelInstruction();
-
- private void SubmitInstruction()
- {
- var text = InstructionBox.Text;
- HideApprovalOverlay();
- _instructionTcs?.TrySetResult(text);
- _instructionTcs = null;
- }
-
- private void CancelInstruction()
- {
- HideApprovalOverlay();
- _instructionTcs?.TrySetResult(ApprovalSignals.Cancelled);
- _instructionTcs = null;
- }
-
- /// An approval raised in a background tab can't steal focus, so MainWindow badges
- /// that tab and raises the toast instead.
- private void ShowApprovalOverlay()
- {
- ApprovalOverlay.Visibility = Visibility.Visible;
- ApprovalStateChanged?.Invoke(this);
- }
-
- private void HideApprovalOverlay()
- {
- ApprovalOverlay.Visibility = Visibility.Collapsed;
- ApprovalDiffList.ItemsSource = null;
- ApprovalStateChanged?.Invoke(this);
- InputBox.Focus(FocusState.Programmatic);
- }
-
- /// Slides the plan-approval bar up above the input. Unlike the modal it doesn't cover the
- /// transcript (the plan stays readable), but it DOES gate input — the turn is awaiting the choice.
- private void ShowPlanApprovalBar(ApprovalRequest request, Action onChosen)
- {
- // Windows 98 theme: the bar drops its rounded card look and reads as a silver
- // dialog strip — square corners, dialog-face background. Rebuilt on every show,
- // so live theme switches take effect on the next approval.
- var win98 = ThemeManager.Current.Win98;
- PlanApprovalBar.CornerRadius = new CornerRadius(win98 ? 0 : 12);
- PlanApprovalBar.Background = (Brush)Application.Current.Resources[
- win98 ? "MandoBackgroundBrush" : "MandoPanelBrush"];
-
- PlanApprovalTitle.Text = request.Title;
-
- // Command approvals ride this bar too: show the command in monospace. The buttons
- // live in a WrapPanel — one horizontal row whenever it fits, wrapping only when the
- // window is too narrow for the long "don't ask again" labels.
- PlanApprovalCommand.Text = string.IsNullOrEmpty(request.CommandText) ? "" : "$ " + request.CommandText;
- PlanApprovalCommand.Visibility = string.IsNullOrEmpty(request.CommandText)
- ? Visibility.Collapsed : Visibility.Visible;
-
- PlanApprovalButtons.Children.Clear();
- foreach (var option in request.Options)
- {
- var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
- if (!string.IsNullOrEmpty(option.Glyph))
- content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
- content.Children.Add(new TextBlock { Text = option.Label });
-
- var button = new Button { Content = content, Tag = option.Label, Padding = new Thickness(14, 6, 14, 6) };
- if (win98) button.CornerRadius = new CornerRadius(0); // square, like every 98 control
- if (option.Kind == ApprovalOptionKind.Proceed)
- button.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; // primary
- else
- button.Foreground = option.Kind == ApprovalOptionKind.Destructive
- ? (SolidColorBrush)Application.Current.Resources["MandoRedBrush"]
- : (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
- if (!string.IsNullOrEmpty(option.Description))
- ToolTipService.SetToolTip(button, option.Description);
- button.Click += (_, _) => onChosen((string)button.Tag);
- PlanApprovalButtons.Children.Add(button);
- }
-
- // Gate input while the plan is awaiting a decision.
- InputBox.IsEnabled = false;
- SendButton.IsEnabled = false;
- EmojiButton.IsEnabled = false;
-
- PlanApprovalBar.Visibility = Visibility.Visible;
- ApprovalStateChanged?.Invoke(this);
-
- var slide = new DoubleAnimation
- {
- From = 24, To = 0,
- Duration = new Duration(TimeSpan.FromMilliseconds(220)),
- EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
- };
- Storyboard.SetTarget(slide, PlanApprovalTransform);
- Storyboard.SetTargetProperty(slide, "Y");
- var fade = new DoubleAnimation
- {
- From = 0, To = 1,
- Duration = new Duration(TimeSpan.FromMilliseconds(180)),
- };
- Storyboard.SetTarget(fade, PlanApprovalBar);
- Storyboard.SetTargetProperty(fade, "Opacity");
- var sb = new Storyboard();
- sb.Children.Add(slide);
- sb.Children.Add(fade);
- sb.Begin();
- }
-
- private void HidePlanApprovalBar()
- {
- if (PlanApprovalBar.Visibility != Visibility.Visible) return;
- PlanApprovalBar.Visibility = Visibility.Collapsed;
- InputBox.IsEnabled = true;
- SendButton.IsEnabled = true;
- EmojiButton.IsEnabled = true;
- ApprovalStateChanged?.Invoke(this);
- InputBox.Focus(FocusState.Programmatic);
- }
-}
-
-/// One row in the header's model dropdown: the model tag plus a cloud/local badge.
-/// Built on the UI thread when the flyout opens, so it can carry ready-made brushes.
-public sealed class ModelItem
-{
- public ModelItem(string name, string badge, Brush badgeForeground, Brush badgeBackground)
- {
- Name = name;
- Badge = badge;
- BadgeForeground = badgeForeground;
- BadgeBackground = badgeBackground;
- }
-
- public string Name { get; }
- public string Badge { get; }
- public Brush BadgeForeground { get; }
- public Brush BadgeBackground { get; }
-}
-
-/// One row in the file-explorer tree. Folder nodes are created with unrealized
-/// children and lazy-load their contents on first expand (ChatTabView.ExplorerTree_Expanding).
-public sealed class ExplorerItem : System.ComponentModel.INotifyPropertyChanged
-{
- public string Name { get; private init; } = "";
- public string FullPath { get; private init; } = "";
- public bool IsDirectory { get; private init; }
-
- /// Root-relative path with forward slashes \u2014 the key used to match this row
- /// against git change entries.
- public string RelPath { get; private init; } = "";
-
- /// The exact @token the row produces (root-relative, forward slashes, trailing
- /// '/' on folders) \u2014 shown in the tag button's tooltip so hovering teaches the @ syntax.
- public string Token { get; private init; } = "";
-
- public string TagTooltip => $"Tag in prompt \u2014 inserts {Token}";
-
- /// Files: this file has uncommitted changes. Folders: something inside does.
- /// Mutable + observable so rows already realized in the tree light up in place when a
- /// git refresh lands (rebuilding the tree would lose expansion state).
- public bool Dirty
- {
- get => _dirty;
- set
- {
- if (_dirty == value) return;
- _dirty = value;
- PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DirtyVisibility)));
- }
- }
- private bool _dirty;
-
- public Visibility DirtyVisibility => _dirty ? Visibility.Visible : Visibility.Collapsed;
-
- public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
-
- public string Glyph => IsDirectory ? "\uE8B7" : "\uE8A5"; // folder / document
-
- /// Resolved per-realization from app resources, so icons pick up live theme
- /// switches the next time rows are created (matching how transcript colors retheme).
- public Brush? IconBrush =>
- Application.Current.Resources[IsDirectory ? "MandoGoldBrush" : "MandoDimBrush"] as Brush;
-
- public static ExplorerItem ForFolder(string path, string root)
- {
- var rel = Rel(path, root);
- return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, RelPath = rel, Token = "@" + rel + "/" };
- }
-
- public static ExplorerItem ForFile(string path, string root)
- {
- var rel = Rel(path, root);
- return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, RelPath = rel, Token = "@" + rel };
- }
-
- private static string Rel(string path, string root) =>
- Path.GetRelativePath(root, path).Replace('\\', '/');
-}
-
-/// One row in the explorer's Changes tab: a working-tree change with its display
-/// letter/color, split name + directory, and the @token its tag button inserts. Built on
-/// the UI thread from a GitQuickStatus snapshot, so it carries ready-made brushes
-/// (same pattern as ModelItem).
-public sealed class GitChangeItem
-{
- public string Kind { get; init; } = "";
- public string KindLabel { get; init; } = "";
- public Brush? KindBrush { get; init; }
- public string Name { get; init; } = "";
- public string Dir { get; init; } = "";
- public string FullPath { get; init; } = "";
- public string RelPath { get; init; } = "";
- public string TagTooltip { get; init; } = "";
-
- /// Undo restores from HEAD, so it needs a HEAD side: hidden for untracked rows
- /// ("undoing" a new file would DELETE it — different action, different UI) and renamed
- /// rows (a clean rename-undo needs both paths).
- public Visibility UndoVisibility => Kind is "M" or "D" or "!" ? Visibility.Visible : Visibility.Collapsed;
-
- public string UndoTooltip => Kind == "D"
- ? "Restore this deleted file"
- : "Undo changes — restore this file to the last commit (asks first)";
}
diff --git a/src/MandoCode.Desktop/MainWindow.Appearance.cs b/src/MandoCode.Desktop/MainWindow.Appearance.cs
new file mode 100644
index 0000000..6dffafe
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Appearance.cs
@@ -0,0 +1,229 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // 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 BoxedMessages_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (!_appearanceReady) return; // see _appearanceReady — a Save() here wipes settings
+ ThemeManager.SetBoxedMessages(BoxedMessagesToggle.IsOn);
+ ApplyThemeToAllTabs(); // live — existing messages re-skin instantly
+ }
+
+ 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}%";
+ 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;
+ // The preview WebView renders the image itself (via the userdata host + theme
+ // script), so there is no XAML image to update here anymore.
+ }
+
+ // 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.
+ private const int GWL_EXSTYLE = -20;
+ private const int WS_EX_LAYERED = 0x80000;
+ private const uint LWA_ALPHA = 0x2;
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
+ private static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
+ private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool SetLayeredWindowAttributes(nint hWnd, uint crKey, byte bAlpha, uint dwFlags);
+
+ private void ApplyWindowOpacity(double opacity)
+ {
+ var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
+ var exStyle = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
+ if (opacity >= 0.995)
+ {
+ SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle & ~(nint)WS_EX_LAYERED);
+ }
+ else
+ {
+ SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle | (nint)WS_EX_LAYERED);
+ SetLayeredWindowAttributes(hwnd, 0, (byte)Math.Round(opacity * 255), LWA_ALPHA);
+ }
+ }
+
+ private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ 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}.";
+ }
+
+ private string _modelComboTarget = "";
+
+ /// An editable ComboBox drops programmatic Text while its template isn't
+ /// loaded (the Settings page starts collapsed) — so the intended model name is kept
+ /// here and re-applied on the combo's Loaded event. Selecting the matching pulled
+ /// model when one exists also marks it in the dropdown.
+ private void ApplyModelComboTarget()
+ {
+ if (_modelComboTarget.Length == 0) return;
+ if (ModelCombo.ItemsSource is IList models)
+ {
+ var idx = models.IndexOf(_modelComboTarget);
+ if (idx >= 0)
+ {
+ ModelCombo.SelectedIndex = idx;
+ return;
+ }
+ }
+ ModelCombo.Text = _modelComboTarget;
+ }
+
+ /// One write path for the whole page: ConfigKeySetter via the controller.
+ private async Task ApplySettingAsync(string key, string value)
+ {
+ var (ok, message) = await _controller.ApplyConfigKeyAsync(key, value);
+ SettingsStatus.Text = message;
+ if (!ok) LoadSettings(); // revert the control to the real value
+ }
+
+ private async void Setting_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (_loadingSettings) return;
+ var toggle = (ToggleSwitch)sender;
+ await ApplySettingAsync((string)toggle.Tag, toggle.IsOn ? "true" : "false");
+ }
+
+ private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
+ {
+ if (_loadingSettings) return;
+
+ // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and
+ // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons
+ // keep working. If even the old value is gone, reload the whole form from config.
+ if (double.IsNaN(args.NewValue))
+ {
+ if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue;
+ else LoadSettings();
+ return;
+ }
+
+ await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString());
+ }
+
+ private async void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+ {
+ if (_loadingSettings) return;
+ S_TemperatureLabel.Text = e.NewValue.ToString("0.##");
+ await ApplySettingAsync("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ private async void Streaming_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (_loadingSettings || S_Streaming.SelectedItem is not string mode) return;
+ await ApplySettingAsync("streaming", mode);
+ }
+
+ /// Enables View as soon as there's anything to reveal (saved key or fresh typing).
+ private void TavilyKey_Changed(object sender, RoutedEventArgs e) =>
+ TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0;
+
+ private void TavilyView_Click(object sender, RoutedEventArgs e)
+ {
+ var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible;
+ S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden;
+ TavilyViewButton.Content = show ? "Hide" : "View";
+ }
+
+ private async void TavilySave_Click(object sender, RoutedEventArgs e)
+ {
+ var key = S_TavilyKey.Password;
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ SettingsStatus.Text = "Enter a key first (or type 'clear' to remove the saved one).";
+ return;
+ }
+ await ApplySettingAsync("tavilyKey", key.Trim());
+ LoadSettings();
+ }
+
+ private async void RefreshModels_Click(object sender, RoutedEventArgs e) =>
+ await RefreshModelListAsync();
+
+ private async Task RefreshModelListAsync()
+ {
+ ModelListStatus.Text = "Fetching models…";
+ var models = await Task.Run(_controller.ListModelsAsync);
+ if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text;
+ ModelCombo.ItemsSource = models;
+ ApplyModelComboTarget();
+ ModelListStatus.Text = models.Count == 0
+ ? "No models found — is Ollama running? (ollama serve, then ollama pull )"
+ : $"{models.Count} model(s) available.";
+ }
+
+ private async void SettingsSave_Click(object sender, RoutedEventArgs e)
+ {
+ var endpoint = EndpointBox.Text;
+ var model = ModelCombo.Text;
+ SettingsStatus.Text = "Connecting… (details land in the chat transcript)";
+ await Task.Run(() => _controller.ApplyConnectionSettingsAsync(endpoint, model));
+ SettingsStatus.Text = _controller.IsConnected
+ ? $"✓ Connected — {_controller.ModelName}"
+ : "Couldn't connect — see the chat transcript for details.";
+ LoadSettings();
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs
new file mode 100644
index 0000000..e1d0bca
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.History.cs
@@ -0,0 +1,475 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // History panel — reopen a closed conversation. Shares the docked column with Snapshots.
+ // ============================================================
+
+ /// Files a just-closed tab into the archive so it can be reopened later. A session that
+ /// never had a real turn is forgotten instead (deleting its files), same as /clear —
+ /// there's nothing worth reopening, and an empty row would only be noise.
+ private void ArchiveClosedSession(AgentSession session)
+ {
+ var key = session.PersistKey;
+ var turns = ConversationLog.Load(key);
+ if (turns.Count == 0)
+ {
+ TranscriptJournal.Delete(key);
+ ConversationLog.Delete(key);
+ SessionHistoryStore.Delete(key);
+ return;
+ }
+
+ var preview = turns.FirstOrDefault(t => t.R == "u")?.T?.Trim();
+ if (preview is { Length: > 140 }) preview = preview[..140].TrimEnd() + "…";
+
+ _archive.Add(new SessionArchiveEntry
+ {
+ Key = key,
+ Title = session.Title,
+ ProjectRoot = session.ProjectRoot.ProjectRoot,
+ Model = session.Controller.ModelName,
+ ClosedAt = DateTimeOffset.Now,
+ TurnCount = turns.Count,
+ Preview = preview,
+ });
+ }
+
+ private void OnArchiveChanged()
+ {
+ if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
+ else RefreshHistoryBadge();
+ }
+
+ /// Marks every current archived conversation as seen, clearing the History rail badge.
+ private void MarkHistorySeen()
+ {
+ _historySeenAt = DateTimeOffset.Now;
+ SavePanelState();
+ RefreshHistoryBadge();
+ }
+
+ private void NavHistory_Click(object sender, RoutedEventArgs e)
+ {
+ if (_historyPanelOpen) CloseLeftPanel();
+ else OpenHistory();
+ }
+
+ private void CloseHistory_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
+
+ private void OpenHistory()
+ {
+ MarkHistorySeen(); // opening the panel IS reading it — clear the unread badge
+ PopulateHistory();
+ ShowLeftPanel(HistoryPanel, snapshots: false);
+ }
+
+ /// Current text in the history search box; empty means "show everything".
+ private string _historyFilter = "";
+
+ /// Project labels whose History group is folded shut (survives search/reopen/delete).
+ private readonly HashSet _collapsedHistoryGroups = new();
+
+ private void HistoryGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
+ {
+ if (sender.Tag is not HistoryGroup g) return;
+ g.IsExpanded = true;
+ _collapsedHistoryGroups.Remove(g.Project);
+ SavePanelState();
+ }
+
+ private void HistoryGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
+ {
+ if (sender.Tag is not HistoryGroup g) return;
+ g.IsExpanded = false;
+ _collapsedHistoryGroups.Add(g.Project);
+ SavePanelState();
+ }
+
+ private void HistorySearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+ {
+ if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
+ _historyFilter = sender.Text?.Trim() ?? "";
+ PopulateHistory();
+ }
+
+ private static bool Matches(SessionArchiveEntry s, string q) =>
+ s.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || (s.Model?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
+ || (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
+
+ private void PopulateHistory()
+ {
+ var all = _archive.Items; // newest-first copy
+ var storeEmpty = all.Count == 0;
+ HistorySearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
+
+ var q = _historyFilter;
+ var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
+
+ // Group by project (freshest project first), newest-first within each, carrying remembered
+ // collapse state — same shape as the Snapshots panel.
+ var groups = filtered
+ .GroupBy(s => s.ProjectLabel)
+ .OrderByDescending(g => g.Max(s => s.ClosedAt))
+ .Select(g => new HistoryGroup(g.Key, g) { IsExpanded = !_collapsedHistoryGroups.Contains(g.Key) })
+ .ToList();
+
+ HistoryList.ItemsSource = groups;
+
+ var nothingToShow = groups.Count == 0;
+ HistoryEmpty.Text = storeEmpty
+ ? "No past conversations yet. Close a tab and it lands here — reopen it any time to pick up where you left off. (Clearing a tab with /clear forgets it for good; closing keeps it.)"
+ : $"No conversations match “{q}”.";
+ HistoryEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
+ HistoryScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
+ RefreshHistoryBadge();
+ }
+
+ private void RefreshHistoryBadge()
+ {
+ // Unread = conversations closed after the last visit. Never-visited (null) counts them all.
+ var n = _historySeenAt is { } seen
+ ? _archive.Items.Count(s => s.ClosedAt > seen)
+ : _archive.Count;
+ NavHistoryBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
+ NavHistoryBadgeText.Text = n > 99 ? "99+" : n.ToString();
+ }
+
+ /// Reopens an archived conversation as a fresh tab on its original persist-key, so the
+ /// standard restore cascade (transcript replay → memory rehydrate) brings it back. The row
+ /// leaves the archive — it's live again — but its files stay; closing re-archives it.
+ private void HistoryOpen_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
+
+ // Defensive: an archived key should never also be open, but if it is, just go there.
+ var existing = _tabs.FirstOrDefault(t =>
+ string.Equals(t.View.Session.PersistKey, entry.Key, StringComparison.OrdinalIgnoreCase));
+ if (existing != null)
+ {
+ _archive.Remove(entry.Key, deleteFiles: false);
+ CloseLeftPanel();
+ SwitchPage("chat");
+ SelectTab(existing);
+ return;
+ }
+
+ // Fall back to the current directory if the original folder is gone — the transcript and
+ // memory still restore; only new file operations would need a live folder.
+ var root = Directory.Exists(entry.ProjectRoot) ? entry.ProjectRoot : Environment.CurrentDirectory;
+ var tab = CreateChatTab(root, entry.Title, entry.Model, entry.Key); // CreateChatTab selects it
+ _archive.Remove(entry.Key, deleteFiles: false);
+ CloseLeftPanel();
+ SwitchPage("chat");
+ _ = InitTabAsync(tab); // InitializeAsync replays the transcript; then model + memory restore
+ SaveWorkspace();
+ }
+
+ private void HistoryDelete_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
+ _archive.Remove(entry.Key, deleteFiles: true); // explicit forget — files go too
+ PopulateHistory();
+ }
+
+ /// "Make Default for New Agents" — snapshot the selected agent's settings to disk.
+ private void MakeDefault_Click(object sender, RoutedEventArgs e)
+ {
+ var agent = _sessions.Active;
+ if (agent == null) return;
+
+ _controller.SaveAsDefaults();
+ SettingsStatus.Text = $"Saved {agent.Title}'s settings as the default for new agents. "
+ + "Agents already open keep their own.";
+ }
+
+ /// Resets the visible tab's settings to the app's factory defaults (this agent, this
+ /// session). Reads a fresh for the defaults and applies each key
+ /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the
+ /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset.
+ private async void ResetTab_Click(object sender, RoutedEventArgs e)
+ {
+ var d = new MandoCodeConfig(); // factory defaults (property initializers)
+ var s = SettingsTabs.SelectedItem;
+ var resets = new List<(string Key, string Value)>();
+ string tabName;
+
+ static string Bool(bool b) => b ? "true" : "false";
+ static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ if (s == Tab_Behavior)
+ {
+ tabName = "Behavior";
+ resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning)));
+ resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals)));
+ resets.Add(("autoContinue", Bool(d.EnableAutoContinuation)));
+ resets.Add(("maxContinuations", Num(d.MaxAutoContinuations)));
+ resets.Add(("timeout", Num(d.RequestTimeoutMinutes)));
+ resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds)));
+ resets.Add(("toolBudget", Num(d.ToolResultCharBudget)));
+ resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds)));
+ }
+ else if (s == Tab_Integrations)
+ {
+ tabName = "Integrations";
+ resets.Add(("webSearch", Bool(d.EnableWebSearch)));
+ }
+ else
+ {
+ tabName = "Model";
+ resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)));
+ resets.Add(("maxTokens", Num(d.MaxTokens)));
+ resets.Add(("contextLength", Num(d.ContextLength)));
+ resets.Add(("streaming", d.ResponseStreaming));
+ }
+
+ ResetTabButton.IsEnabled = false;
+ foreach (var (key, value) in resets)
+ await _controller.ApplyConfigKeyAsync(key, value);
+ ResetTabButton.IsEnabled = true;
+
+ LoadSettings(); // reflect the restored values (also clears the status line)
+ SettingsStatus.Text = $"{tabName} settings reset to factory defaults.";
+ }
+
+ private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title)
+ {
+ var label = new TextBlock
+ {
+ Text = title,
+ FontSize = 13,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+
+ // Gold dot: an approval is waiting in a tab you aren't looking at.
+ var badge = new Ellipse
+ {
+ Width = 7,
+ Height = 7,
+ Visibility = Visibility.Collapsed,
+ VerticalAlignment = VerticalAlignment.Center,
+ Fill = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
+ };
+
+ // Options "..." menu (rename / snapshot / export / close) replaces a bare close button — so
+ // the last remaining tab isn't stuck showing an X it isn't allowed to use.
+ var options = new Button
+ {
+ Padding = new Thickness(3),
+ Background = new SolidColorBrush(Colors.Transparent),
+ BorderThickness = new Thickness(0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Content = new FontIcon { Glyph = "", FontSize = 12 } // More
+ };
+ ToolTipService.SetToolTip(options, "Tab options");
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(options, "Tab options");
+
+ // A Grid (not a StackPanel) so the label flexes and ellipsizes when the tab is narrow,
+ // while the badge and options button stay pinned at the right. LayoutTabStrip sets each
+ // header's Width; this just governs how that width is divided.
+ var row = new Grid { ColumnSpacing = 7 };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ Grid.SetColumn(label, 0);
+ Grid.SetColumn(badge, 1);
+ Grid.SetColumn(options, 2);
+ row.Children.Add(label);
+ row.Children.Add(badge);
+ row.Children.Add(options);
+
+ var header = new Border
+ {
+ Child = row,
+ Padding = new Thickness(12, 6, 8, 6),
+ CornerRadius = new CornerRadius(7),
+ BorderThickness = new Thickness(1),
+ BorderBrush = new SolidColorBrush(Colors.Transparent),
+ Background = new SolidColorBrush(Colors.Transparent)
+ };
+ return (header, label, badge);
+ }
+
+ /// Wired after the entry exists so the menu handlers can close over it.
+ private void WireHeader(ChatTabEntry entry)
+ {
+ // The options Button consumes the pointer, so opening its menu doesn't also raise Tapped
+ // on the header. Selecting first would be harmless anyway.
+ entry.Header.Tapped += (_, _) => SelectTab(entry);
+
+ var row = (Grid)entry.Header.Child;
+ var options = (Button)row.Children[^1];
+
+ var menu = new MenuFlyout();
+
+ var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } };
+ rename.Click += (_, _) => _ = RenameTabAsync(entry);
+
+ var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } };
+ snapshot.Click += (_, _) => entry.View.TakeSnapshotManually();
+
+ var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } };
+ export.Click += (_, _) => _ = entry.View.ExportTranscriptAsync();
+
+ var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
+ close.Click += (_, _) => CloseTab(entry);
+
+ menu.Items.Add(rename);
+ menu.Items.Add(snapshot);
+ menu.Items.Add(export);
+ menu.Items.Add(new MenuFlyoutSeparator());
+ menu.Items.Add(close);
+
+ // Closing the last agent is allowed now — it leaves an empty chat (see EnterEmptyState);
+ // Settings/MCP simply disable until a new agent exists.
+
+ options.Flyout = menu;
+ }
+
+ /// Renames a tab via a small dialog. The name is display-only (the folder stays in
+ /// the header); it survives folder changes and model switches.
+ private async Task RenameTabAsync(ChatTabEntry entry)
+ {
+ var box = new TextBox { Text = entry.View.Session.Title };
+ box.SelectAll();
+
+ var dialog = new ContentDialog
+ {
+ Title = "Rename agent",
+ Content = box,
+ PrimaryButtonText = "Rename",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Primary,
+ XamlRoot = Content.XamlRoot,
+ };
+
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ var name = box.Text.Trim();
+ if (name.Length == 0) return;
+
+ entry.View.Session.Title = name;
+ entry.Label.Text = name;
+ RefreshTabStrip();
+ }
+
+ /// Selecting an agent also returns you to the chat page — the Settings you were
+ /// looking at belonged to the agent you just left.
+ private void SelectTab(ChatTabEntry entry)
+ {
+ // Selecting a tab NEVER changes the compare pair — it only changes the active agent. If that
+ // agent is in the pair, ApplyPaneLayout shows the split; otherwise it shows the agent single.
+ _selected = entry;
+ _sessions.Activate(entry.View.Session);
+ RefreshTabStrip();
+ SwitchPage("chat");
+
+ // Reveal the selected tab. Try now (covers clicking an already-laid-out tab) and again when
+ // the strip re-lays-out (covers a just-added agent, whose width/extent settle a frame later,
+ // via TabStrip_SizeChanged). Pending stays set until the tab is actually laid out.
+ _scrollToSelectedPending = true;
+ DispatcherQueue.TryEnqueue(TryScrollToSelected);
+ }
+
+ private bool _scrollToSelectedPending;
+
+ // Scroll the strip so the selected tab is fully visible — a manual ChangeView so a newly created
+ // (last) tab scrolls ALL THE WAY to the end. StartBringIntoView only did a minimal scroll and ran
+ // before the extent settled, so it stopped short. No-op once the tab is visible; stays pending
+ // (retried on the next strip SizeChanged) while the tab isn't laid out yet (ActualWidth == 0).
+ private void TryScrollToSelected()
+ {
+ if (!_scrollToSelectedPending || _selected is null) return;
+ var header = _selected.Header;
+ if (header.ActualWidth <= 0) return; // not laid out yet — retry on the next SizeChanged
+
+ double left = header.TransformToVisual(TabStrip)
+ .TransformPoint(new Windows.Foundation.Point(0, 0)).X;
+ double right = left + header.ActualWidth;
+ double viewLeft = TabScroller.HorizontalOffset;
+ double viewRight = viewLeft + TabScroller.ViewportWidth;
+ const double pad = 8;
+
+ if (right > viewRight) // off the right (e.g. a just-added last tab)
+ TabScroller.ChangeView(right - TabScroller.ViewportWidth + pad, null, null);
+ else if (left < viewLeft) // off the left
+ TabScroller.ChangeView(Math.Max(0, left - pad), null, null);
+
+ _scrollToSelectedPending = false;
+ }
+
+ private void TabStrip_SizeChanged(object sender, SizeChangedEventArgs e) => TryScrollToSelected();
+
+ private void CloseTab(ChatTabEntry entry)
+ {
+ var index = _tabs.IndexOf(entry);
+ if (index < 0) return;
+
+ _tabs.RemoveAt(index);
+ TabStrip.Children.Remove(entry.Header);
+
+ // Shut down BEFORE unparenting. Removing the view from the tree unloads the WebView2 and
+ // nulls its CoreWebView2, so Close() and any last transcript write would hit null.
+ entry.View.Shutdown();
+ TabHost.Children.Remove(entry.View);
+ _sessions.CloseSession(entry.View.Session);
+ ArchiveClosedSession(entry.View.Session); // closed tab = recoverable from History, not gone
+
+ // Closing the last agent is allowed: you're left with the empty chat background until you
+ // open another. Settings/MCP disable meanwhile (they act on an agent), handled in SwitchPage.
+ if (_tabs.Count == 0)
+ {
+ _selected = null;
+ ValidateSplit(); // nothing left to compare → exits split
+ EnterEmptyState();
+ SaveWorkspace();
+ return;
+ }
+
+ if (!ReferenceEquals(_selected, entry))
+ {
+ ValidateSplit(); // repair the right pane if that's what closed
+ RefreshTabStrip();
+ SaveWorkspace();
+ return;
+ }
+
+ _selected = null;
+ SelectTab(_tabs[Math.Min(index, _tabs.Count - 1)]);
+ ValidateSplit(); // the new selection might collide with the right pane
+ SaveWorkspace();
+ }
+
+ /// Shows the "no agents open" background — the chat area with nothing in it. Bounces off
+ /// any full-screen page back to chat (Settings/MCP have no agent to act on now).
+ private void EnterEmptyState()
+ {
+ RefreshTabStrip(); // empties the toast; disables Settings/MCP via RefreshNavIcons
+ SwitchPage("chat"); // reveals the empty-state panel + its background
+ if (_snapshotsPanelOpen) PopulateSnapshots(); // no agent now → disable Import + show notice
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Mcp.cs b/src/MandoCode.Desktop/MainWindow.Mcp.cs
new file mode 100644
index 0000000..c3f3fe4
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Mcp.cs
@@ -0,0 +1,390 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // MCP page
+ // ============================================================
+
+ private async void McpRefresh_Click(object sender, RoutedEventArgs e) => await RefreshMcpListAsync();
+
+ // Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter).
+ private List _allMcpRows = new();
+ private bool _loadingMcp;
+
+ private async Task RefreshMcpListAsync()
+ {
+ // Servers are shared across agents and enabled/disabled per-server now, so MCP is always on
+ // at the agent level. Make sure the active agent actually attaches tools (new agents inherit
+ // EnableMcp=true from defaults; this only fires for an agent someone turned off previously).
+ if (!_controller.Config.EnableMcp)
+ await ApplySettingAsync("mcp", "true");
+
+ McpPageStatus.Text = "Checking server status…";
+ var rows = await Task.Run(_controller.GetMcpStatusRowsAsync);
+
+ var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
+ var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+ _allMcpRows = rows.Select(r => new McpRow
+ {
+ Name = r.Name,
+ Transport = r.Transport,
+ Status = r.Status,
+ StatusBrush = r.Connected ? green : gold,
+ Enabled = !r.Disabled,
+ }).ToList();
+
+ ApplyMcpFilter();
+ }
+
+ private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
+ ApplyMcpFilter();
+
+ private string _mcpFilter = "all";
+
+ private void McpFilter_Click(object sender, RoutedEventArgs e)
+ {
+ _mcpFilter = (string)((FrameworkElement)sender).Tag;
+ McpFilterAll.IsChecked = _mcpFilter == "all";
+ McpFilterEnabled.IsChecked = _mcpFilter == "enabled";
+ McpFilterDisabled.IsChecked = _mcpFilter == "disabled";
+ McpFilterFailed.IsChecked = _mcpFilter == "failed";
+ ApplyMcpFilter();
+ }
+
+ /// Applies search + active chip, then groups into Enabled/Disabled sections. The
+ /// programmatic ItemsSource set realizes rows (firing each toggle), guarded in McpEnabled_Toggled.
+ private void ApplyMcpFilter()
+ {
+ var q = McpSearchBox.Text?.Trim() ?? "";
+ IEnumerable filtered = _allMcpRows;
+ if (!string.IsNullOrEmpty(q))
+ filtered = filtered.Where(r =>
+ r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ r.Transport.Contains(q, StringComparison.OrdinalIgnoreCase));
+ filtered = _mcpFilter switch
+ {
+ "enabled" => filtered.Where(r => r.Enabled),
+ "disabled" => filtered.Where(r => !r.Enabled),
+ "failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
+ _ => filtered,
+ };
+ var shown = filtered.ToList();
+
+ var groups = new List();
+ var en = shown.Where(r => r.Enabled).ToList();
+ var dis = shown.Where(r => !r.Enabled).ToList();
+ if (en.Count > 0) groups.Add(new McpRowGroup($"Enabled ({en.Count})", en));
+ if (dis.Count > 0) groups.Add(new McpRowGroup($"Disabled ({dis.Count})", dis));
+
+ var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
+ _loadingMcp = true;
+ McpList.ItemsSource = cvs.View;
+ _loadingMcp = false;
+
+ McpEditButton.IsEnabled = false;
+ McpRemoveButton.IsEnabled = false;
+
+ var total = _allMcpRows.Count;
+ var enabledTotal = _allMcpRows.Count(r => r.Enabled);
+ var active = q.Length > 0 || _mcpFilter != "all";
+ if (total == 0)
+ McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one.";
+ else if (active)
+ McpPageStatus.Text = $"{shown.Count} of {total} shown · {enabledTotal} enabled";
+ else
+ McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled";
+ }
+
+ /// Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts
+ /// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload).
+ private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
+ {
+ // Fires while the list realizes rows and binds IsOn — ignore those (state already matches).
+ if (_loadingMcp) return;
+ if (sender is not ToggleSwitch sw || sw.DataContext is not McpRow row) return;
+ if (sw.IsOn == row.Enabled) return;
+
+ // Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
+ if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
+ server.Disabled = !sw.IsOn;
+
+ McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
+ await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
+ await RefreshMcpListAsync();
+ }
+
+ /// Runs a slash command through the normal pipeline (transcript echo, wizard
+ /// overlays, busy state all included), then refreshes the server list.
+ private async Task RunMcpCommandAsync(string command)
+ {
+ if (_controller.IsProcessing)
+ {
+ McpPageStatus.Text = "Busy — wait for the current request to finish.";
+ return;
+ }
+ await Task.Run(() => _controller.SubmitAsync(command));
+ await RefreshMcpListAsync();
+ }
+
+ private void McpAdd_Click(object sender, RoutedEventArgs e) => OpenMcpEditor(null);
+
+ private void McpEdit_Click(object sender, RoutedEventArgs e)
+ {
+ if (McpList.SelectedItem is not McpRow row)
+ {
+ McpPageStatus.Text = "Select a server to edit first.";
+ return;
+ }
+ OpenMcpEditor(row.Name);
+ }
+
+ private void McpList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var hasSelection = McpList.SelectedItem is McpRow;
+ McpEditButton.IsEnabled = hasSelection;
+ McpRemoveButton.IsEnabled = hasSelection;
+ }
+
+ private void McpList_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
+ {
+ if (McpList.SelectedItem is McpRow row) OpenMcpEditor(row.Name);
+ }
+
+ private async void McpRemove_Click(object sender, RoutedEventArgs e)
+ {
+ if (McpList.SelectedItem is not McpRow row)
+ {
+ McpPageStatus.Text = "Select a server to remove first.";
+ return;
+ }
+ await RunMcpCommandAsync($"/mcp remove {row.Name}");
+ }
+
+ private async void McpReload_Click(object sender, RoutedEventArgs e) =>
+ await RunMcpCommandAsync("/mcp-reload");
+
+ // ============================================================
+ // MCP server editor modal (add + edit)
+ // ============================================================
+
+ private string? _mcpEditOriginalName;
+
+ private void OpenMcpEditor(string? serverName)
+ {
+ _mcpEditOriginalName = serverName;
+ M_StatusBar.IsOpen = false;
+ M_TestToolsTable.Visibility = Visibility.Collapsed;
+ M_TestSpin.Visibility = Visibility.Collapsed;
+ McpEditorTestButton.IsEnabled = true;
+ McpEditorSaveButton.IsEnabled = true;
+
+ if (serverName != null && _controller.Config.McpServers.TryGetValue(serverName, out var cfg))
+ {
+ McpEditorTitle.Text = $"Edit MCP server — {serverName}";
+ McpEditorSaveButton.Content = "Save & Reconnect";
+ M_Name.Text = serverName;
+ M_Transport.SelectedIndex = cfg.IsHttp ? 1 : 0;
+ M_Command.Text = cfg.Command ?? "";
+ M_Args.Text = string.Join(" ", cfg.Args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a));
+ M_Env.Text = string.Join("\n", cfg.Env.Select(kv => $"{kv.Key}={kv.Value}"));
+ M_Url.Text = cfg.Url ?? "";
+ M_Headers.Text = string.Join("\n", cfg.Headers.Select(kv => $"{kv.Key}={kv.Value}"));
+ M_Disabled.IsOn = cfg.Disabled;
+ }
+ else
+ {
+ McpEditorTitle.Text = "Add MCP server";
+ McpEditorSaveButton.Content = "Save & Connect";
+ M_Name.Text = "";
+ M_Transport.SelectedIndex = 0;
+ M_Command.Text = "";
+ M_Args.Text = "";
+ M_Env.Text = "";
+ M_Url.Text = "";
+ M_Headers.Text = "";
+ M_Disabled.IsOn = false;
+ }
+
+ UpdateMcpTransportPanels();
+ McpEditorOverlay.Visibility = Visibility.Visible;
+ M_Name.Focus(FocusState.Programmatic);
+ }
+
+ private void M_Transport_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
+ UpdateMcpTransportPanels();
+
+ private void UpdateMcpTransportPanels()
+ {
+ // Guard: fires during InitializeComponent before panels exist.
+ if (M_StdioPanel == null || M_HttpPanel == null) return;
+ var isHttp = M_Transport.SelectedIndex == 1;
+ M_HttpPanel.Visibility = isHttp ? Visibility.Visible : Visibility.Collapsed;
+ M_StdioPanel.Visibility = isHttp ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void McpEditorCancel_Click(object sender, RoutedEventArgs e) =>
+ McpEditorOverlay.Visibility = Visibility.Collapsed;
+
+ private void ShowMcpEditorError(string message)
+ {
+ M_TestSpin.IsActive = false;
+ M_TestSpin.Visibility = Visibility.Collapsed;
+ M_TestToolsTable.Visibility = Visibility.Collapsed;
+ M_StatusBar.Severity = InfoBarSeverity.Error;
+ M_StatusBar.Title = "Check the form";
+ M_StatusBar.Message = message;
+ M_StatusBar.IsOpen = true;
+ }
+
+ /// Parses "KEY=value" lines. Returns null (with an error shown) on a bad line.
+ private Dictionary? ParseKeyValueLines(string text, string label)
+ {
+ var dict = new Dictionary();
+ foreach (var rawLine in text.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0) continue;
+ var eq = line.IndexOf('=');
+ if (eq <= 0)
+ {
+ ShowMcpEditorError($"{label}: '{line}' isn't KEY=value.");
+ return null;
+ }
+ dict[line[..eq].Trim()] = line[(eq + 1)..].Trim();
+ }
+ return dict;
+ }
+
+ /// Shared validate-and-build for Test and Save. Shows the error inline and
+ /// returns false when the form isn't valid.
+ private bool TryBuildServerFromForm(bool checkNameCollision, out string name, out MandoCode.Models.McpServerConfig server)
+ {
+ M_StatusBar.IsOpen = false;
+ server = new MandoCode.Models.McpServerConfig { Disabled = M_Disabled.IsOn };
+
+ // Lowercased — servers are referenced by name in tool prefixes (mcp_).
+ name = M_Name.Text.Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(name)) { ShowMcpEditorError("Name cannot be empty."); return false; }
+ if (name.Contains(' ')) { ShowMcpEditorError("Name cannot contain spaces."); return false; }
+ if (checkNameCollision && _mcpEditOriginalName == null && _controller.Config.McpServers.ContainsKey(name))
+ {
+ ShowMcpEditorError($"A server named '{name}' already exists — edit it instead, or pick another name.");
+ return false;
+ }
+
+ if (M_Transport.SelectedIndex == 1) // http
+ {
+ var url = M_Url.Text.Trim();
+ if (!Uri.TryCreate(url, UriKind.Absolute, out _))
+ {
+ ShowMcpEditorError("URL must be absolute (e.g. https://mcp.example.com/mcp).");
+ return false;
+ }
+ server.Url = url;
+ server.Transport = "http";
+
+ var headers = ParseKeyValueLines(M_Headers.Text, "Headers");
+ if (headers == null) return false;
+ server.Headers = headers;
+ }
+ else // stdio
+ {
+ var command = M_Command.Text.Trim();
+ if (string.IsNullOrWhiteSpace(command)) { ShowMcpEditorError("Command cannot be empty."); return false; }
+ server.Command = command;
+ server.Args = ChatController.ParseShellLikeArgs(M_Args.Text.Trim());
+
+ var env = ParseKeyValueLines(M_Env.Text, "Environment variables");
+ if (env == null) return false;
+ server.Env = env;
+ }
+
+ return true;
+ }
+
+ private async void McpEditorTest_Click(object sender, RoutedEventArgs e)
+ {
+ // No collision check — testing an existing name is fine, nothing is written.
+ if (!TryBuildServerFromForm(checkNameCollision: false, out var name, out var server)) return;
+
+ M_StatusBar.Severity = InfoBarSeverity.Informational;
+ M_StatusBar.Title = "Testing connection…";
+ M_StatusBar.Message = "Connecting with these values — nothing is saved, running servers aren't touched.";
+ M_StatusBar.IsOpen = true;
+ M_TestToolsTable.Visibility = Visibility.Collapsed;
+ M_TestSpin.Visibility = Visibility.Visible;
+ M_TestSpin.IsActive = true;
+ McpEditorTestButton.IsEnabled = false;
+ McpEditorSaveButton.IsEnabled = false;
+
+ try
+ {
+ var result = await Task.Run(() => _controller.TestMcpServerAsync(name, server));
+
+ M_TestSpin.IsActive = false;
+ M_TestSpin.Visibility = Visibility.Collapsed;
+
+ if (result.Ok)
+ {
+ M_StatusBar.Severity = InfoBarSeverity.Success;
+ M_StatusBar.Title = $"Connected — {result.Tools.Count} tool(s)";
+ M_StatusBar.Message = result.Message;
+ if (result.Tools.Count > 0)
+ {
+ M_TestTools.ItemsSource = result.Tools
+ .Select(t => new ToolChip { Name = t.Name, Description = t.Description ?? "(no description)" })
+ .ToList();
+ M_TestToolsTable.Visibility = Visibility.Visible;
+ }
+ }
+ else
+ {
+ M_StatusBar.Severity = InfoBarSeverity.Error;
+ M_StatusBar.Title = "Connection failed";
+ M_StatusBar.Message = result.Message;
+ }
+ }
+ finally
+ {
+ M_TestSpin.IsActive = false;
+ McpEditorTestButton.IsEnabled = true;
+ McpEditorSaveButton.IsEnabled = true;
+ }
+ }
+
+ private async void McpEditorSave_Click(object sender, RoutedEventArgs e)
+ {
+ if (!TryBuildServerFromForm(checkNameCollision: true, out var name, out var server)) return;
+
+ McpEditorOverlay.Visibility = Visibility.Collapsed;
+ SwitchPage("mcp");
+ McpPageStatus.Text = $"Saving '{name}' and connecting…";
+
+ var originalName = _mcpEditOriginalName;
+ var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server));
+ McpPageStatus.Text = message;
+ await RefreshMcpListAsync();
+ McpPageStatus.Text = message;
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Navigation.cs b/src/MandoCode.Desktop/MainWindow.Navigation.cs
new file mode 100644
index 0000000..4c74797
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Navigation.cs
@@ -0,0 +1,157 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Sidebar navigation — Settings and MCP are full-screen pages, not tabs. They act on
+ // whichever agent is selected, so switching pages never changes which agent that is.
+ // ============================================================
+
+ private string _currentPage = "chat";
+
+ private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat");
+
+ // Settings/MCP act as toggles: clicking the one you're already on closes it and returns to the
+ // last active agent, rather than reloading the page in place.
+ private void NavSettings_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "settings" ? "chat" : "settings");
+ private void NavMcp_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "mcp" ? "chat" : "mcp");
+ private void NavSkills_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "skills" ? "chat" : "skills");
+ private void NavAppearance_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "appearance" ? "chat" : "appearance");
+
+ private void SwitchPage(string page)
+ {
+ // Settings and MCP act on the selected agent — with none open there's nothing to edit, so
+ // fall back to the (empty) chat. Skills and Appearance are app-global and stay reachable.
+ if ((page == "settings" || page == "mcp") && _sessions.Active == null) page = "chat";
+
+ _currentPage = page;
+ var showingChat = page == "chat";
+
+ SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed;
+ McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed;
+ SkillsPage.Visibility = page == "skills" ? Visibility.Visible : Visibility.Collapsed;
+ AppearancePage.Visibility = page == "appearance" ? Visibility.Visible : Visibility.Collapsed;
+
+ // Glide the full-screen page in from the rail side (translate + fade). Both run on the
+ // composition thread, so the whole page slides smoothly regardless of how much it holds.
+ if (page == "settings") SlideInPage(SettingsPage, SettingsPageTransform);
+ else if (page == "mcp") SlideInPage(McpPage, McpPageTransform);
+ else if (page == "skills") SlideInPage(SkillsPage, SkillsPageTransform);
+ else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform);
+
+ // Every agent view stays loaded; only the visible one(s) show, and only on the chat page.
+ // Collapsing rather than removing is what keeps each WebView2's transcript alive. In split
+ // mode two views show at once (the compare pair, _compareA left / _compareB right).
+ ApplyPaneLayout();
+
+ // The empty-state background shows only on the chat page with no agents left.
+ EmptyAgentsState.Visibility = showingChat && _tabs.Count == 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ _ = RefreshEmptyBackgroundAsync();
+
+ RefreshNavIcons();
+ // Re-evaluate the approval toast for the new page — leaving the chat can newly "hide" the
+ // selected agent's approval, which should now raise the toast (and returning clears it).
+ RefreshTabStrip();
+
+ switch (page)
+ {
+ case "settings":
+ LoadSettings();
+ _ = RefreshModelListAsync();
+ break;
+ case "mcp":
+ _ = RefreshMcpListAsync();
+ break;
+ case "skills":
+ RefreshSkillsList();
+ break;
+ default:
+ ActiveChat?.FocusInput();
+ break;
+ }
+ }
+
+ /// Slides a full-screen page (Settings/MCP) into view from the rail side, with a short
+ /// fade. Translate and Opacity are independent animations, so this stays smooth on the
+ /// composition thread no matter how much the page contains.
+ private static void SlideInPage(UIElement page, TranslateTransform transform)
+ {
+ var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+
+ var slide = new DoubleAnimation
+ {
+ From = -48,
+ To = 0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(260)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(slide, transform);
+ Storyboard.SetTargetProperty(slide, "X");
+
+ var fade = new DoubleAnimation
+ {
+ From = 0,
+ To = 1,
+ Duration = new Duration(TimeSpan.FromMilliseconds(200)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(fade, page);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+
+ var sb = new Storyboard();
+ sb.Children.Add(slide);
+ sb.Children.Add(fade);
+ sb.Begin();
+ }
+
+ private void RefreshNavIcons()
+ {
+ var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+ var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+ var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+
+ // An approval waiting in ANY agent while you're on Settings/MCP: the agents icon goes gold,
+ // because from here you can't see which tab is badged.
+ var approvalPending = _currentPage != "chat" && _tabs.Any(t => t.View.IsApprovalOpen);
+
+ NavChatIcon.Foreground = _currentPage == "chat" ? accent : (approvalPending ? gold : normal);
+ NavSettingsIcon.Foreground = _currentPage == "settings" ? accent : normal;
+ NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal;
+ NavSkillsIcon.Foreground = _currentPage == "skills" ? accent : normal;
+ NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal;
+ NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal;
+ NavHistoryIcon.Foreground = _historyPanelOpen ? accent : normal;
+ NavTerminalIcon.Foreground = _terminalOpen ? accent : normal;
+
+ // Settings and MCP act on the selected agent — disable them while none is open.
+ var hasAgent = _sessions.Active != null;
+ NavSettings.IsEnabled = hasAgent;
+ NavMcp.IsEnabled = hasAgent;
+ ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents");
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Settings.cs b/src/MandoCode.Desktop/MainWindow.Settings.cs
new file mode 100644
index 0000000..b23913d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Settings.cs
@@ -0,0 +1,100 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Settings page
+ // ============================================================
+
+ private bool _loadingSettings;
+
+ /// Populates every control from the live config. Guarded so control-change
+ /// events fired during population don't write back.
+ private void LoadSettings()
+ {
+ _loadingSettings = true;
+ try
+ {
+ // The SELECTED agent's config, not the saved defaults. Switch agents and this page
+ // shows different values.
+ var cfg = _controller.Config;
+ SettingsAgentChip.Text = _sessions.Active?.Title ?? "";
+ EndpointBox.Text = cfg.OllamaEndpoint;
+ _modelComboTarget = cfg.GetEffectiveModelName();
+ ApplyModelComboTarget();
+ S_ContextLength.Value = cfg.ContextLength;
+ S_Temperature.Value = cfg.Temperature;
+ S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##");
+ S_MaxTokens.Value = cfg.MaxTokens;
+ S_Streaming.SelectedItem = cfg.ResponseStreaming;
+ S_TaskPlanning.IsOn = cfg.EnableTaskPlanning;
+ S_DiffApprovals.IsOn = cfg.EnableDiffApprovals;
+ S_AutoContinue.IsOn = cfg.EnableAutoContinuation;
+ S_MaxContinuations.Value = cfg.MaxAutoContinuations;
+ S_RequestTimeout.Value = cfg.RequestTimeoutMinutes;
+ S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds;
+ S_ToolBudget.Value = cfg.ToolResultCharBudget;
+ S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds;
+ S_WebSearch.IsOn = cfg.EnableWebSearch;
+ S_TavilyKey.Password = cfg.TavilyApiKey ?? "";
+ S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden;
+ TavilyViewButton.Content = "View";
+ TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey);
+ for (int i = 0; i < UiTheme.All.Count; i++)
+ if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i;
+ SettingsStatus.Text = "";
+ }
+ finally
+ {
+ _loadingSettings = false;
+ }
+ }
+
+ private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
+ {
+ var s = sender.SelectedItem;
+ TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed;
+ TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed;
+ TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed;
+
+ // "Reset" acts on the visible tab, so its label names that tab.
+ ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior"
+ : s == Tab_Integrations ? "Reset Integrations" : "Reset Model";
+ // Every remaining tab is per-agent now (Appearance moved to its own rail page), so
+ // "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)
+ {
+ if (!_appearanceReady) return;
+ S_WindowOpacityLabel.Text = $"{(int)e.NewValue}%";
+ ThemeManager.SetWindowOpacity(e.NewValue / 100.0);
+ ApplyWindowOpacity(ThemeManager.WindowOpacity);
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Shared.cs b/src/MandoCode.Desktop/MainWindow.Shared.cs
new file mode 100644
index 0000000..bd7fd8d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Shared.cs
@@ -0,0 +1,129 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Appearance-page live preview — a real miniature transcript
+ // ============================================================
+ // Same shell + theme script as the tabs, so it shows EXACTLY what they show (background
+ // image, boxed messages, E-Ink dithering, W98 chrome). Failures never break settings —
+ // the preview is a luxury.
+
+ private bool _previewWebReady;
+
+ private async void InitBgPreview()
+ {
+ try
+ {
+ await BgPreviewWeb.EnsureCoreWebView2Async();
+ var core = BgPreviewWeb.CoreWebView2;
+ core.Settings.AreDefaultContextMenusEnabled = false;
+
+ // Same virtual hosts the tabs map: assets (highlight.js) + userdata (bg image).
+ try
+ {
+ core.SetVirtualHostNameToFolderMapping(
+ "mandocode.assets",
+ System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "web"),
+ Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
+ }
+ catch { /* already mapped / missing assets — preview still renders */ }
+ try
+ {
+ Directory.CreateDirectory(ThemeManager.UserDataFolder);
+ core.SetVirtualHostNameToFolderMapping(
+ "mandocode.userdata", ThemeManager.UserDataFolder,
+ Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
+ }
+ catch { /* already mapped / no user-data folder — preview still renders */ }
+
+ core.NavigationCompleted += (_, _) =>
+ {
+ if (_previewWebReady) return;
+ _previewWebReady = true;
+ _ = SeedBgPreviewAsync();
+ };
+ core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
+ }
+ catch { /* no preview — settings still fully functional */ }
+ }
+
+ private async Task SeedBgPreviewAsync()
+ {
+ try
+ {
+ var blocks =
+ _html.UserEcho("how does this look?") +
+ _html.AssistantCard(
+ "Like this — the image fades, the text never does.\n\n" +
+ "Inline `code` and a block, to judge every surface:\n\n" +
+ "```csharp\nvar vibe = \"immaculate\";\n```");
+ await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ "window.__append(" + JsonSerializer.Serialize(blocks) + ");");
+ await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ ThemeManager.BuildTranscriptScript(ThemeManager.Current));
+ }
+ catch { /* preview seeding is cosmetic; the real transcript is unaffected */ }
+ }
+
+ private void OnUi(Action action)
+ {
+ if (_dispatcher.HasThreadAccess) action();
+ else _dispatcher.TryEnqueue(() => action());
+ }
+
+ private void Root_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Escape) { ActiveChat?.HandleEscape(); return; }
+
+ // Ctrl+` toggles the terminal; Ctrl+Shift+` opens a new shell tab (VS Code parity).
+ // 192 == VK_OEM_3 (backtick/tilde). Handled here rather than via a KeyboardAccelerator:
+ // WinUI fast-fails natively when an accelerator is registered on an OEM key.
+ if (e.Key == (VirtualKey)192 && IsDown(VirtualKey.Control))
+ {
+ e.Handled = true;
+ if (IsDown(VirtualKey.Shift)) { OpenTerminalPanel(); _terminal!.NewTerminalTab(); }
+ else ToggleTerminal();
+ }
+ }
+
+ private static bool IsDown(VirtualKey key) =>
+ Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(key)
+ .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+
+ private void ApplyThemeToAllTabs()
+ {
+ foreach (var tab in _tabs) tab.View.ApplyTheme();
+ // The appearance preview is a transcript too — it re-themes with everyone else.
+ if (_previewWebReady && BgPreviewWeb.CoreWebView2 != null)
+ _ = BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+ ThemeManager.BuildTranscriptScript(ThemeManager.Current));
+ }
+
+ private void CopyToClipboard(string text)
+ {
+ var package = new DataPackage();
+ package.SetText(text);
+ Clipboard.SetContent(package);
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Skills.cs b/src/MandoCode.Desktop/MainWindow.Skills.cs
new file mode 100644
index 0000000..d5bf840
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Skills.cs
@@ -0,0 +1,456 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Skills page — global (user) skills. All file work lives in SkillCoordinator; this is just
+ // the UI + the fan-out call that makes a change land in every open agent's prompt.
+ // ============================================================
+
+ private string? _editingSkillFolder;
+
+ // Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter).
+ private List _allSkillRows = new();
+
+ private void RefreshSkillsList()
+ {
+ _allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
+ {
+ Name = s.Name,
+ Description = s.Description,
+ Body = s.Body,
+ FolderPath = s.FolderPath,
+ Enabled = s.Enabled,
+ }).ToList();
+
+ ApplySkillFilter();
+ }
+
+ private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
+ ApplySkillFilter();
+
+ private string _skillFilter = "all";
+
+ private void SkillFilter_Click(object sender, RoutedEventArgs e)
+ {
+ _skillFilter = (string)((FrameworkElement)sender).Tag;
+ // Single-select: light the chosen chip, clear the rest.
+ SkillFilterAll.IsChecked = _skillFilter == "all";
+ SkillFilterEnabled.IsChecked = _skillFilter == "enabled";
+ SkillFilterDisabled.IsChecked = _skillFilter == "disabled";
+ SkillFilterLarge.IsChecked = _skillFilter == "large";
+ ApplySkillFilter();
+ }
+
+ /// Applies the search text + active chip, then groups the result into Enabled/Disabled
+ /// sections. Runs on every refresh and keystroke, so filters survive enable/install/delete.
+ private void ApplySkillFilter()
+ {
+ var q = SkillSearchBox.Text?.Trim() ?? "";
+ IEnumerable filtered = _allSkillRows;
+ if (!string.IsNullOrEmpty(q))
+ filtered = filtered.Where(r =>
+ r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ r.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
+ filtered = _skillFilter switch
+ {
+ "enabled" => filtered.Where(r => r.Enabled),
+ "disabled" => filtered.Where(r => !r.Enabled),
+ "large" => filtered.Where(r => r.IsLarge),
+ _ => filtered,
+ };
+ var shown = filtered.ToList();
+
+ // Group by state — Enabled first, Disabled below; empty sections omitted.
+ var groups = new List();
+ var en = shown.Where(r => r.Enabled).ToList();
+ var dis = shown.Where(r => !r.Enabled).ToList();
+ if (en.Count > 0) groups.Add(new SkillRowGroup($"Enabled ({en.Count})", en));
+ if (dis.Count > 0) groups.Add(new SkillRowGroup($"Disabled ({dis.Count})", dis));
+
+ var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
+ SkillsList.ItemsSource = cvs.View;
+
+ // Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it.
+ SkillEditButton.IsEnabled = false;
+ SkillDeleteButton.IsEnabled = false;
+
+ var total = _allSkillRows.Count;
+ var enabledTotal = _allSkillRows.Count(r => r.Enabled);
+ var active = q.Length > 0 || _skillFilter != "all";
+ if (total == 0)
+ SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one. ({_skillCoordinator.UserSkillsDirectory})";
+ else if (active)
+ SkillsPageStatus.Text = $"{shown.Count} of {total} shown · {enabledTotal} enabled";
+ else
+ SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled · {_skillCoordinator.UserSkillsDirectory}";
+ }
+
+ /// Reload every agent's skill set + prompt, then re-render the list and report.
+ private async Task ApplySkillChangeAsync(string status)
+ {
+ await _skillCoordinator.ReloadAllAsync();
+ RefreshSkillsList();
+ SkillsPageStatus.Text = status;
+ }
+
+ private void SkillRefresh_Click(object sender, RoutedEventArgs e) => RefreshSkillsList();
+
+ private void SkillsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var has = SkillsList.SelectedItem is SkillRow;
+ SkillEditButton.IsEnabled = has;
+ SkillDeleteButton.IsEnabled = has;
+ }
+
+ private void SkillsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
+ }
+
+ private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
+ {
+ // Toggled also fires while the list realizes rows and binds IsOn from the row. In that case
+ // the new state equals the row's stored state — a no-op we must ignore, or realizing the
+ // list would rewrite files. A real user flip makes the two differ.
+ if (sender is not ToggleSwitch sw || sw.DataContext is not SkillRow row) return;
+ if (sw.IsOn == row.Enabled) return;
+
+ try
+ {
+ _skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
+ await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
+ }
+ catch (Exception ex)
+ {
+ SkillsPageStatus.Text = ex.Message;
+ }
+ }
+
+ private void SkillNew_Click(object sender, RoutedEventArgs e) => OpenSkillEditor(null);
+
+ private void SkillEdit_Click(object sender, RoutedEventArgs e)
+ {
+ if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
+ }
+
+ private async void SkillDelete_Click(object sender, RoutedEventArgs e)
+ {
+ if (SkillsList.SelectedItem is not SkillRow row) return;
+
+ var dialog = new ContentDialog
+ {
+ Title = "Delete skill",
+ Content = $"Delete “{row.Name}”? This removes its folder from disk and can't be undone.",
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = Content.XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ try
+ {
+ _skillCoordinator.DeleteSkill(row.FolderPath);
+ await ApplySkillChangeAsync($"Deleted “{row.Name}”.");
+ }
+ catch (Exception ex)
+ {
+ SkillsPageStatus.Text = ex.Message;
+ }
+ }
+
+ private void SkillOpenFolder_Click(object sender, RoutedEventArgs e)
+ {
+ var dir = _skillCoordinator.UserSkillsDirectory;
+ try { System.IO.Directory.CreateDirectory(dir); }
+ catch (Exception ex) { SkillsPageStatus.Text = ex.Message; return; }
+ if (ShellOpen.Try(dir) is { } err) SkillsPageStatus.Text = err.Message;
+ }
+
+ // ---- Skill editor modal ----
+
+ private void OpenSkillEditor(SkillRow? row)
+ {
+ Sk_StatusBar.IsOpen = false;
+ if (row == null)
+ {
+ _editingSkillFolder = null;
+ SkillEditorTitle.Text = "New skill";
+ Sk_Name.Text = "";
+ Sk_Description.Text = "";
+ Sk_Body.Text = "";
+ }
+ else
+ {
+ _editingSkillFolder = row.FolderPath;
+ SkillEditorTitle.Text = "Edit skill";
+ Sk_Name.Text = row.Name;
+ Sk_Description.Text = row.Description;
+ Sk_Body.Text = row.Body;
+ }
+
+ // Reset the AI panel and default its model to the active agent's (still changeable).
+ Sk_AiIntent.Text = "";
+ SetSkillAiBusy(false, "");
+ _ = LoadSkillAuthorModelsAsync(_sessions.Active?.Controller.ModelName ?? "");
+
+ UpdateSkillBodySize(); // explicit: setting Text="" above won't fire TextChanged if already empty
+ SkillEditorOverlay.Visibility = Visibility.Visible;
+ Sk_Name.Focus(FocusState.Programmatic);
+ }
+
+ private void Sk_Body_TextChanged(object sender, TextChangedEventArgs e) => UpdateSkillBodySize();
+
+ /// Live size readout for the instructions body — approximate tokens, gold when large,
+ /// matching the size column in the skills list.
+ private void UpdateSkillBodySize()
+ {
+ var chars = Sk_Body.Text?.Length ?? 0;
+ var tokens = (chars + 3) / 4;
+ var large = tokens >= 2000;
+ Sk_BodySize.Text = (tokens >= 1000 ? $"≈{tokens / 1000.0:0.0}k tokens" : $"≈{tokens} tokens")
+ + (large ? " · large — heavy on local models" : "");
+ Sk_BodySize.Foreground = new SolidColorBrush(
+ ThemeManager.C(large ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
+ }
+
+ /// Fills the AI model dropdown: the active agent's model shown selected instantly, then
+ /// the full installed-model list streamed in behind it. Mirrors the snapshot picker.
+ private async Task LoadSkillAuthorModelsAsync(string activeModel)
+ {
+ if (string.IsNullOrWhiteSpace(activeModel))
+ {
+ Sk_AiModel.ItemsSource = null;
+ return;
+ }
+
+ var current = new ModelChoice(activeModel, MandoCodeConfig.IsCloudModel(activeModel));
+ Sk_AiModel.ItemsSource = new List { current };
+ Sk_AiModel.SelectedIndex = 0;
+
+ var result = await _controller.LoadAvailableModelsAsync();
+ if (!result.Ok || result.Models.Count == 0) return;
+
+ // If the user already picked another model while the list loaded, don't clobber it.
+ if ((Sk_AiModel.SelectedItem as ModelChoice)?.Name != activeModel) return;
+
+ var choices = result.Models
+ .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
+ .ToList();
+ if (!choices.Any(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase)))
+ choices.Insert(0, current);
+
+ Sk_AiModel.ItemsSource = choices;
+ Sk_AiModel.SelectedItem =
+ choices.First(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void SetSkillAiBusy(bool busy, string status)
+ {
+ Sk_AiSpin.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ Sk_AiSpin.IsActive = busy;
+ Sk_GenerateButton.IsEnabled = !busy;
+ Sk_RefineButton.IsEnabled = !busy;
+ Sk_AiStatus.Text = status;
+ }
+
+ private async void SkillGenerate_Click(object sender, RoutedEventArgs e)
+ {
+ var intent = Sk_AiIntent.Text.Trim();
+ if (intent.Length == 0) { Sk_AiStatus.Text = "Describe what the skill should do first."; return; }
+ if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
+
+ Sk_StatusBar.IsOpen = false;
+ SetSkillAiBusy(true, "Drafting…");
+ try
+ {
+ var endpoint = _sessions.Active!.Config.OllamaEndpoint;
+ var draft = await SkillAuthor.GenerateAsync(endpoint, model.Name, intent);
+ if (!string.IsNullOrWhiteSpace(draft.Name)) Sk_Name.Text = draft.Name;
+ if (!string.IsNullOrWhiteSpace(draft.Description)) Sk_Description.Text = draft.Description;
+ if (!string.IsNullOrWhiteSpace(draft.Body)) Sk_Body.Text = draft.Body;
+ SetSkillAiBusy(false, "Draft ready — review and edit before saving.");
+ }
+ catch (Exception ex)
+ {
+ SetSkillAiBusy(false, "");
+ ShowSkillEditorError($"AI draft failed: {ex.Message}");
+ }
+ }
+
+ private async void SkillRefine_Click(object sender, RoutedEventArgs e)
+ {
+ var instruction = Sk_AiIntent.Text.Trim();
+ if (instruction.Length == 0) { Sk_AiStatus.Text = "Type what to change in the box above."; return; }
+ if (Sk_Body.Text.Trim().Length == 0) { Sk_AiStatus.Text = "Nothing to refine yet — write or generate instructions first."; return; }
+ if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
+
+ Sk_StatusBar.IsOpen = false;
+ SetSkillAiBusy(true, "Refining…");
+ try
+ {
+ var endpoint = _sessions.Active!.Config.OllamaEndpoint;
+ var body = await SkillAuthor.RefineAsync(endpoint, model.Name, Sk_Body.Text, instruction);
+ if (!string.IsNullOrWhiteSpace(body)) Sk_Body.Text = body;
+ SetSkillAiBusy(false, "Instructions updated.");
+ }
+ catch (Exception ex)
+ {
+ SetSkillAiBusy(false, "");
+ ShowSkillEditorError($"AI refine failed: {ex.Message}");
+ }
+ }
+
+ private void SkillEditorCancel_Click(object sender, RoutedEventArgs e) =>
+ SkillEditorOverlay.Visibility = Visibility.Collapsed;
+
+ private void ShowSkillEditorError(string message)
+ {
+ Sk_StatusBar.Title = "Check the form";
+ Sk_StatusBar.Message = message;
+ Sk_StatusBar.IsOpen = true;
+ }
+
+ private async void SkillEditorSave_Click(object sender, RoutedEventArgs e)
+ {
+ var name = Sk_Name.Text.Trim();
+ if (name.Length == 0) { ShowSkillEditorError("Give the skill a name."); return; }
+ if (Sk_Body.Text.Trim().Length == 0) { ShowSkillEditorError("The instructions can't be empty."); return; }
+
+ try
+ {
+ _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
+ SkillEditorOverlay.Visibility = Visibility.Collapsed;
+ await ApplySkillChangeAsync($"Saved “{name}”.");
+ }
+ catch (Exception ex)
+ {
+ ShowSkillEditorError(ex.Message);
+ }
+ }
+
+ // ---- Skill install modal ----
+
+ private void SkillInstall_Click(object sender, RoutedEventArgs e)
+ {
+ Sk_InstallMode.SelectedIndex = 0; // Git by default
+ Sk_InstallGitPanel.Visibility = Visibility.Visible;
+ Sk_InstallLocalPanel.Visibility = Visibility.Collapsed;
+ Sk_InstallGitUrl.Text = "";
+ Sk_InstallLocalPath.Text = "";
+ Sk_InstallStatus.Text = "";
+ Sk_InstallError.IsOpen = false;
+ Sk_InstallSpin.IsActive = false;
+ Sk_InstallSpin.Visibility = Visibility.Collapsed;
+ SkillInstallConfirmButton.IsEnabled = true;
+ SkillInstallOverlay.Visibility = Visibility.Visible;
+ Sk_InstallGitUrl.Focus(FocusState.Programmatic);
+ }
+
+ private void SkillInstallMode_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ // Fires during InitializeComponent before the panels exist.
+ if (Sk_InstallGitPanel == null || Sk_InstallLocalPanel == null) return;
+ var local = Sk_InstallMode.SelectedIndex == 1;
+ Sk_InstallGitPanel.Visibility = local ? Visibility.Collapsed : Visibility.Visible;
+ Sk_InstallLocalPanel.Visibility = local ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FolderPicker();
+ picker.FileTypeFilter.Add("*");
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+ var folder = await picker.PickSingleFolderAsync();
+ if (folder != null) Sk_InstallLocalPath.Text = folder.Path;
+ }
+
+ private async void SkillBrowseZip_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FileOpenPicker();
+ picker.FileTypeFilter.Add(".zip");
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+ var file = await picker.PickSingleFileAsync();
+ if (file != null) Sk_InstallLocalPath.Text = file.Path;
+ }
+
+ private void SkillInstallCancel_Click(object sender, RoutedEventArgs e) =>
+ SkillInstallOverlay.Visibility = Visibility.Collapsed;
+
+ private async void SkillInstallConfirm_Click(object sender, RoutedEventArgs e)
+ {
+ var source = (Sk_InstallMode.SelectedIndex == 1 ? Sk_InstallLocalPath.Text : Sk_InstallGitUrl.Text).Trim();
+ if (source.Length == 0)
+ {
+ Sk_InstallError.Title = "Nothing to install";
+ Sk_InstallError.Message = "Enter a git URL, a .zip path, or a folder path.";
+ Sk_InstallError.IsOpen = true;
+ return;
+ }
+
+ Sk_InstallError.IsOpen = false;
+ Sk_InstallSpin.Visibility = Visibility.Visible;
+ Sk_InstallSpin.IsActive = true;
+ Sk_InstallStatus.Text = "Fetching…";
+ SkillInstallConfirmButton.IsEnabled = false;
+
+ try
+ {
+ // Clone / extract / copy can block; keep it off the UI thread.
+ var result = await Task.Run(() => _skillCoordinator.InstallFrom(source));
+
+ // Nothing found: keep the modal open so the user can fix the source, and say what a
+ // valid source looks like. (finally still resets the spinner/button below.)
+ if (result.Installed.Count == 0 && result.Skipped.Count == 0)
+ {
+ Sk_InstallError.Title = "No skills found";
+ Sk_InstallError.Message = "That source has no SKILL.md. A skill is a folder containing a SKILL.md file — point at one, or at a folder/repo/.zip that holds them (nested is fine).";
+ Sk_InstallError.IsOpen = true;
+ return;
+ }
+
+ SkillInstallOverlay.Visibility = Visibility.Collapsed;
+ await _skillCoordinator.ReloadAllAsync();
+ RefreshSkillsList();
+
+ var parts = new List();
+ if (result.Installed.Count > 0) parts.Add($"installed {string.Join(", ", result.Installed)}");
+ if (result.Skipped.Count > 0) parts.Add($"skipped (already present): {string.Join(", ", result.Skipped)}");
+ SkillsPageStatus.Text = string.Join(" · ", parts);
+ }
+ catch (Exception ex)
+ {
+ Sk_InstallError.Title = "Install failed";
+ Sk_InstallError.Message = ex.Message;
+ Sk_InstallError.IsOpen = true;
+ }
+ finally
+ {
+ Sk_InstallSpin.IsActive = false;
+ Sk_InstallSpin.Visibility = Visibility.Collapsed;
+ Sk_InstallStatus.Text = "";
+ SkillInstallConfirmButton.IsEnabled = true;
+ }
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Snapshots.cs b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
new file mode 100644
index 0000000..39f9af1
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
@@ -0,0 +1,242 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Snapshots panel — global (the store is app-wide), toggled from the rail. Docked left at
+ // ~37% width so the active chat stays visible; Import arms the selected agent's next message.
+ // ============================================================
+
+ private void NavSnapshots_Click(object sender, RoutedEventArgs e)
+ {
+ if (_snapshotsPanelOpen) CloseLeftPanel();
+ else OpenSnapshots();
+ }
+
+ private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
+
+ private void OpenSnapshots()
+ {
+ MarkSnapshotsSeen(); // opening the panel IS reading it — clear the unread badge
+ PopulateSnapshots();
+ ShowLeftPanel(SnapshotsPanel, snapshots: true);
+ }
+
+ /// Shows one of the two docked panels (Snapshots/History), swapping if the other was
+ /// already up (the column stays out — only the contents change) and sliding it in otherwise.
+ private void ShowLeftPanel(Border panel, bool snapshots)
+ {
+ bool wasOpen = _snapshotsPanelOpen || _historyPanelOpen;
+ _snapshotsPanelOpen = snapshots;
+ _historyPanelOpen = !snapshots;
+ SnapshotsPanel.Visibility = snapshots ? Visibility.Visible : Visibility.Collapsed;
+ HistoryPanel.Visibility = snapshots ? Visibility.Collapsed : Visibility.Visible;
+ RefreshNavIcons();
+ if (wasOpen) return; // column already at width — contents swapped, no re-slide
+
+ // Target ~37% of the content area (everything right of the 48px rail), matching the old
+ // 0.6* / 1* split. Computed in pixels at open time so the tween can drive the column.
+ double target = Math.Max(320, (Root.ActualWidth - 48) * 0.375);
+ AnimateLeftColumn(target, hideOnDone: null);
+ }
+
+ private void CloseLeftPanel()
+ {
+ var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel
+ : _historyPanelOpen ? HistoryPanel : null;
+ _snapshotsPanelOpen = false;
+ _historyPanelOpen = false;
+ RefreshNavIcons();
+ AnimateLeftColumn(0, hideOnDone: toHide);
+ }
+
+ /// Tweens the docked column width to with an ease-out curve,
+ /// gliding the panel open or closed. Re-entrant: a click mid-slide retargets from the current
+ /// width rather than restarting from the edge. , when set, is
+ /// collapsed once a close tween lands.
+ private void AnimateLeftColumn(double toPx, FrameworkElement? hideOnDone)
+ {
+ // Drop any in-flight tween so rapid toggles can't stack Rendering handlers.
+ if (_snapAnimHandler != null) CompositionTarget.Rendering -= _snapAnimHandler;
+
+ _snapAnimFrom = SnapshotsColumn.Width.IsAbsolute ? SnapshotsColumn.Width.Value : 0;
+ _snapAnimTo = toPx;
+ _snapAnimHide = hideOnDone;
+ _snapAnimClock.Restart();
+
+ _snapAnimHandler = (_, _) =>
+ {
+ double t = Math.Clamp(_snapAnimClock.Elapsed.TotalMilliseconds / SnapAnimDurationMs, 0, 1);
+ double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
+ double w = _snapAnimFrom + (_snapAnimTo - _snapAnimFrom) * eased;
+ SnapshotsColumn.Width = new GridLength(w, GridUnitType.Pixel);
+
+ if (t >= 1)
+ {
+ CompositionTarget.Rendering -= _snapAnimHandler;
+ _snapAnimHandler = null;
+ _snapAnimClock.Stop();
+ if (_snapAnimHide != null) _snapAnimHide.Visibility = Visibility.Collapsed;
+ }
+ };
+ CompositionTarget.Rendering += _snapAnimHandler;
+ }
+
+ private void OnSnapshotsChanged()
+ {
+ // A change while you're looking at the panel is already seen; otherwise it's a new unread.
+ if (_snapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
+ else RefreshSnapshotsBadge();
+ }
+
+ /// Marks every current snapshot as seen (opening the panel, or a change while it's open),
+ /// clearing the rail badge. Persisted so the badge doesn't re-light on relaunch.
+ private void MarkSnapshotsSeen()
+ {
+ _snapshotsSeenAt = DateTimeOffset.Now;
+ SavePanelState();
+ RefreshSnapshotsBadge();
+ }
+
+ /// Current text in the snapshots search box; empty means "show everything".
+ private string _snapshotFilter = "";
+
+ /// Project labels whose group is folded shut. Survives repopulation (search, import,
+ /// delete) so a collapse the user made doesn't spring back open on the next keystroke.
+ private readonly HashSet _collapsedSnapshotGroups = new();
+
+ // "Last opened" watermarks — the rail badges show how many snapshots/closed conversations are
+ // newer than these, i.e. unread since the last visit. Persisted in panel-state.json.
+ private DateTimeOffset? _snapshotsSeenAt;
+ private DateTimeOffset? _historySeenAt;
+
+ /// Writes both panels' fold state and seen-watermarks to disk (survives relaunch).
+ private void SavePanelState() => PanelState.Save(new PanelStateShape(
+ _collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
+ _snapshotsSeenAt, _historySeenAt));
+
+ // The group object is kept in sync (not just the set) so that when the ListView recycles a
+ // container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
+ private void SnapshotGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
+ {
+ if (sender.Tag is not SnapshotGroup g) return;
+ g.IsExpanded = true;
+ _collapsedSnapshotGroups.Remove(g.Project);
+ SavePanelState();
+ }
+
+ private void SnapshotGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
+ {
+ if (sender.Tag is not SnapshotGroup g) return;
+ g.IsExpanded = false;
+ _collapsedSnapshotGroups.Add(g.Project);
+ SavePanelState();
+ }
+
+ private void SnapshotsSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+ {
+ // Only react to the user typing — not to programmatic Text changes on repopulate.
+ if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
+ _snapshotFilter = sender.Text?.Trim() ?? "";
+ PopulateSnapshots();
+ }
+
+ private static bool Matches(ContextSnapshot s, string q) =>
+ s.DisplayTitle.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || s.OriginModel.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || s.SummarizerModel.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
+
+ private void PopulateSnapshots()
+ {
+ var all = _snapshotStore.Items; // newest-first copy of the shared store
+ var storeEmpty = all.Count == 0;
+
+ // The search box only earns its space once there's something to search.
+ SnapshotsSearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
+
+ // Explain the disabled Import buttons when there's a snapshot but no agent to import into.
+ SnapshotsNoAgentNotice.IsOpen = !storeEmpty && _sessions.Active == null;
+
+ var q = _snapshotFilter;
+ var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
+
+ // Group by project, preserving the store's newest-first order within each group and
+ // ordering the groups by their most-recent snapshot (so the freshest project leads).
+ // Each group carries its remembered expand/collapse state so folding a project sticks
+ // across searches and imports (which both rebuild this list).
+ var groups = filtered
+ .GroupBy(s => s.ProjectLabel)
+ .OrderByDescending(g => g.Max(s => s.CapturedAt))
+ .Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
+ .ToList();
+
+ SnapshotsList.ItemsSource = groups;
+
+ var nothingToShow = groups.Count == 0;
+ SnapshotsEmpty.Text = storeEmpty
+ ? "No snapshots yet. When you switch a tab's model — or pick Take snapshot from a tab's ⋯ menu — you'll be offered to save the conversation as a snapshot, summarized by a model you choose."
+ : $"No snapshots match “{q}”.";
+ SnapshotsEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
+ SnapshotsScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
+ RefreshSnapshotsBadge();
+ }
+
+ private void RefreshSnapshotsBadge()
+ {
+ // Unread = snapshots captured after the last visit. Never-visited (null) counts them all.
+ var n = _snapshotsSeenAt is { } seen
+ ? _snapshotStore.Items.Count(s => s.CapturedAt > seen)
+ : _snapshotStore.Count;
+ NavSnapshotsBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
+ NavSnapshotsBadgeText.Text = n > 99 ? "99+" : n.ToString();
+ }
+
+ /// Each Import button disables itself when there's no agent to import into — the action
+ /// arms an agent's next message, so it's meaningless with none open. Re-evaluated on load, and the
+ /// list is repopulated when the agent count crosses zero (so open buttons refresh too).
+ private void SnapshotImport_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button b) b.IsEnabled = _sessions.Active != null;
+ }
+
+ private void SnapshotImport_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
+ var target = _selected?.View;
+ if (target == null) return; // no agent open — nothing to import into (button is disabled too)
+
+ target.Session.Controller.ImportContext(snap); // arms the active agent's next message
+ SwitchPage("chat"); // so the "context armed" note is visible in the active tab
+ if (_snapshotsPanelOpen) CloseLeftPanel(); // get out of the way — the chat is where the confirmation shows
+ target.FocusInput();
+ }
+
+ private void SnapshotDelete_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
+ _snapshotStore.Remove(snap);
+ PopulateSnapshots();
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Split.cs b/src/MandoCode.Desktop/MainWindow.Split.cs
new file mode 100644
index 0000000..c42b553
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Split.cs
@@ -0,0 +1,346 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Split / compare view — two agents side by side. The compare PAIR (_compareA left, _compareB
+ // right) is a remembered, explicit choice: set only by the Split button and the compare-bar
+ // pickers, NEVER by clicking a tab. The split is shown whenever the active tab (_selected) is one
+ // of the pair; clicking any other tab shows that agent normally while the pair waits, and
+ // clicking a paired tab brings the split back. Both panes are ordinary tab views moved between
+ // grid columns via ApplyPaneLayout — never reparented, so their WebViews survive.
+ // ============================================================
+
+ private ChatTabEntry? _compareA; // left pane
+ private ChatTabEntry? _compareB; // right pane
+ private double _splitLeftFraction = 0.5; // divider position, preserved across page visits
+ private bool _syncingSplitCombos;
+ private bool _draggingPane;
+
+ /// A valid, distinct compare pair is configured (both agents still open).
+ private bool HasComparePair =>
+ _compareA != null && _compareB != null
+ && _tabs.Contains(_compareA) && _tabs.Contains(_compareB)
+ && !ReferenceEquals(_compareA, _compareB);
+
+ /// The split is actually being shown right now: a pair exists, we're on the chat page,
+ /// and the active tab is one of the two paired agents (clicking any other agent shows it single).
+ private bool SplitActive =>
+ HasComparePair && _currentPage == "chat" && _selected != null
+ && (ReferenceEquals(_selected, _compareA) || ReferenceEquals(_selected, _compareB));
+
+ private void SplitButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (HasComparePair)
+ {
+ // Toggle: showing the split → turn compare off; pair configured but viewing another
+ // agent → jump back into the split.
+ if (SplitActive) ExitSplit();
+ else if (_compareA != null) SelectTab(_compareA);
+ return;
+ }
+ if (_tabs.Count < 2 || _selected == null) return; // button is disabled here anyway
+
+ _compareA = _selected;
+ _compareB = _tabs.FirstOrDefault(t => !ReferenceEquals(t, _selected));
+ RefreshSplitCombos();
+ SwitchPage("chat"); // _selected is in the pair → ApplyPaneLayout shows the split
+ RefreshSplitButton();
+ }
+
+ private void ExitSplit_Click(object sender, RoutedEventArgs e) => ExitSplit();
+
+ private void ExitSplit()
+ {
+ _compareA = null;
+ _compareB = null;
+ ApplyPaneLayout();
+ RefreshSplitButton();
+ }
+
+ /// Places the visible agent view(s) into columns and sizes them. Single view: column 0
+ /// fills (divider + right column collapse to 0). Split: _compareA in column 0, _compareB in
+ /// column 2, divider between. Setting Grid.Column does NOT reparent, so WebViews are untouched.
+ private void ApplyPaneLayout()
+ {
+ var split = SplitActive;
+ var showingChat = _currentPage == "chat";
+
+ foreach (var tab in _tabs)
+ {
+ bool inPair = ReferenceEquals(tab, _compareA) || ReferenceEquals(tab, _compareB);
+ var visible = showingChat && (split ? inPair : ReferenceEquals(tab, _selected));
+ tab.View.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+ Grid.SetColumn(tab.View, split && ReferenceEquals(tab, _compareB) ? 2 : 0);
+ }
+
+ if (split)
+ {
+ PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
+ PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
+ PaneSplitCol.Width = GridLength.Auto;
+ PaneSplitter.Visibility = Visibility.Visible;
+ SplitBar.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ PaneLeftCol.Width = new GridLength(1, GridUnitType.Star);
+ PaneSplitCol.Width = new GridLength(0);
+ PaneRightCol.Width = new GridLength(0);
+ PaneSplitter.Visibility = Visibility.Collapsed;
+ SplitBar.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ /// Re-fills the two pane pickers and re-selects the sides. Items are plain STRINGS
+ /// (agent titles) selected by INDEX into — deliberately NOT ComboBoxItem
+ /// objects: adding containers directly as items and rebuilding them makes WinUI's ComboBox throw
+ /// COMException 0x80070490 "Element not found" on the next selection. Each combo gets its own
+ /// list instance (a shared ItemsSource across two ComboBoxes is asking for trouble).
+ private void RefreshSplitCombos()
+ {
+ _syncingSplitCombos = true;
+ SplitLeftCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
+ SplitRightCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
+ SplitLeftCombo.SelectedIndex = _compareA == null ? -1 : _tabs.IndexOf(_compareA);
+ SplitRightCombo.SelectedIndex = _compareB == null ? -1 : _tabs.IndexOf(_compareB);
+ _syncingSplitCombos = false;
+ }
+
+ // Both pickers defer their ENTIRE reaction to the next dispatcher tick. A ComboBox raises
+ // SelectionChanged from inside a layout pass, and the reaction restructures the visual tree
+ // (moves a ChatTabView + its WebView between grid columns) and rebuilds the pickers — both
+ // illegal mid-layout / mid-event and the source of the App-level crash. Off the event, on a
+ // clean tick, they're safe. Picking an agent for one pane that's already the other pane swaps
+ // the two. The chosen agent becomes active, so the split stays on screen.
+ private void SplitLeftCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_syncingSplitCombos) return;
+ var idx = SplitLeftCombo.SelectedIndex;
+ if (idx < 0 || idx >= _tabs.Count) return;
+ var entry = _tabs[idx];
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareA)) return;
+ if (ReferenceEquals(entry, _compareB)) _compareB = _compareA; // swap sides
+ _compareA = entry;
+ RefreshSplitCombos();
+ SelectTab(entry); // make the left pane active so the split stays shown
+ });
+ }
+
+ private void SplitRightCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_syncingSplitCombos) return;
+ var idx = SplitRightCombo.SelectedIndex;
+ if (idx < 0 || idx >= _tabs.Count) return;
+ var entry = _tabs[idx];
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareB)) return;
+ if (ReferenceEquals(entry, _compareA)) _compareA = _compareB; // swap sides
+ _compareB = entry;
+ RefreshSplitCombos();
+ SelectTab(entry); // make the right pane active so the split stays shown
+ });
+ }
+
+ /// Keeps the compare pair valid after the agent set changes. If either paired agent was
+ /// closed the pair is dropped (compare turns off); otherwise the pickers are resynced.
+ private void ValidateSplit()
+ {
+ if (_compareA == null && _compareB == null) return; // no compare configured
+ if (!HasComparePair)
+ {
+ _compareA = null;
+ _compareB = null;
+ ApplyPaneLayout();
+ RefreshSplitButton();
+ return;
+ }
+ RefreshSplitCombos();
+ ApplyPaneLayout();
+ RefreshSplitButton();
+ }
+
+ private void RefreshSplitButton()
+ {
+ SplitButton.IsEnabled = HasComparePair || _tabs.Count >= 2;
+ var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+ var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+ // Accent whenever a compare pair is configured — even while viewing a non-paired agent — so
+ // it reads as "compare is on; click a paired tab (or me) to see it."
+ SplitButtonIcon.Foreground = HasComparePair ? accent : normal;
+ }
+
+ // ---- divider drag: repartition the two panes' star widths by pointer X over TabHost ----
+ private void PaneSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ _draggingPane = true;
+ ((UIElement)sender).CapturePointer(e.Pointer);
+ }
+
+ private void PaneSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingPane) return;
+ var w = TabHost.ActualWidth;
+ if (w <= 0) return;
+ var x = e.GetCurrentPoint(TabHost).Position.X;
+ _splitLeftFraction = Math.Clamp(x / w, 0.2, 0.8); // keep both panes usable
+ PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
+ PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
+ }
+
+ private void PaneSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingPane) return;
+ _draggingPane = false;
+ ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+ }
+
+ /// Paints the custom chat background image behind the empty state, so closing every
+ /// agent leaves the same backdrop you'd see behind a transcript — same file and opacity. Hidden
+ /// when there's no image set, or when an agent is open (its own WebView paints it then). Loaded
+ /// via a StorageFile stream, the reliable path for an arbitrary filesystem image in unpackaged
+ /// WinUI; best-effort, so a missing/locked file just falls back to the flat themed colour.
+ private async Task RefreshEmptyBackgroundAsync()
+ {
+ var show = _currentPage == "chat" && _tabs.Count == 0;
+ var file = ThemeManager.ChatBackgroundFile;
+ if (!show || string.IsNullOrEmpty(file) || !File.Exists(file))
+ {
+ EmptyBgImage.Visibility = Visibility.Collapsed;
+ EmptyBgImage.Source = null;
+ return;
+ }
+ try
+ {
+ var sf = await Windows.Storage.StorageFile.GetFileFromPathAsync(file);
+ using var stream = await sf.OpenReadAsync();
+ var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage();
+ await bmp.SetSourceAsync(stream);
+ EmptyBgImage.Source = bmp;
+ EmptyBgImage.Opacity = ThemeManager.ChatBackgroundOpacity;
+ EmptyBgImage.Visibility = Visibility.Visible;
+ }
+ catch
+ {
+ EmptyBgImage.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private void RefreshTabStrip()
+ {
+ var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"];
+ var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+ var background = (SolidColorBrush)Application.Current.Resources["MandoBackgroundBrush"];
+ var transparent = new SolidColorBrush(Colors.Transparent);
+
+ ChatTabEntry? pending = null;
+
+ foreach (var tab in _tabs)
+ {
+ var isSelected = ReferenceEquals(tab, _selected);
+ tab.Header.Background = isSelected ? background : transparent;
+ tab.Header.BorderBrush = isSelected ? accent : border;
+ tab.Label.Foreground = isSelected ? accent : dim;
+
+ tab.View.IsSelected = isSelected;
+ var badged = tab.View.IsApprovalOpen && !isSelected;
+ tab.Badge.Visibility = badged ? Visibility.Visible : Visibility.Collapsed;
+
+ // Toast for any approval you can't currently see: a background tab, OR the selected tab
+ // while you're away on Settings/MCP/Appearance (its chat — and the approval — is
+ // collapsed there, so without this you'd get no notice at all).
+ if (tab.View.IsApprovalOpen && (!isSelected || _currentPage != "chat"))
+ pending ??= tab;
+ }
+
+ // With several agents running, "an approval is waiting" is useless without saying where,
+ // so the toast names the agent and selecting it is one click.
+ _pendingApprovalTab = pending;
+ if (pending != null && !_approvalToastDismissed)
+ {
+ ApprovalToastText.Text = pending.View.ApprovalHeadline;
+ ApprovalToastTarget.Text = $"Click to review in \"{pending.View.Session.Title}\"";
+ ApprovalToast.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ ApprovalToast.Visibility = Visibility.Collapsed;
+ if (pending == null) _approvalToastDismissed = false; // next approval earns a fresh toast
+ }
+
+ RefreshNavIcons();
+ RefreshSplitButton();
+ LayoutTabStrip();
+ }
+
+ // Tabs stay a comfortable width when there's room, and only shrink once enough agents are open
+ // that they'd otherwise overflow — down to a floor, past which the strip scrolls instead.
+ private const double TabComfortableWidth = 200;
+ private const double TabMinWidth = 104;
+
+ private void LayoutTabStrip()
+ {
+ int count = _tabs.Count;
+ if (count == 0) return;
+
+ // The visible strip is the scroller's viewport; a later SizeChanged fixes up the first
+ // pass if it hasn't been measured yet (ActualWidth == 0 during early layout).
+ double viewport = TabScroller.ActualWidth; // tabs only — the add button now lives outside
+ if (viewport <= 0) return;
+
+ double spacing = 4 * Math.Max(0, count - 1); // 4px between adjacent tabs
+ double avail = viewport - spacing - 8; // margin so rounding never forces a scrollbar
+
+ double per = Math.Max(TabMinWidth, Math.Min(TabComfortableWidth, avail / count));
+ foreach (var tab in _tabs)
+ tab.Header.Width = per;
+ }
+
+ private void TabScroller_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTabStrip();
+
+ // Mouse wheel scrolls the strip horizontally when there are more tabs than fit — a convenience
+ // on top of the visible scrollbar (which sits in a reserved bottom lane so it never overlaps
+ // the tabs). Touchpad / touch horizontal scrolling works natively.
+ private void TabScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
+ {
+ if (TabScroller.ScrollableWidth <= 0) return; // everything fits; nothing to scroll
+ var delta = e.GetCurrentPoint(TabScroller).Properties.MouseWheelDelta;
+ TabScroller.ChangeView(TabScroller.HorizontalOffset - delta, null, null);
+ e.Handled = true;
+ }
+
+ private void ApprovalToast_Tapped(object sender, TappedRoutedEventArgs e)
+ {
+ if (_pendingApprovalTab != null) SelectTab(_pendingApprovalTab);
+ }
+
+ private void ApprovalToastDismiss_Click(object sender, RoutedEventArgs e)
+ {
+ _approvalToastDismissed = true;
+ ApprovalToast.Visibility = Visibility.Collapsed;
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs
new file mode 100644
index 0000000..93e037d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs
@@ -0,0 +1,95 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Tabs
+ // ============================================================
+
+ /// One independent agent: its strip header and its chat surface.
+ private sealed class ChatTabEntry
+ {
+ public required Border Header { get; init; }
+ public required TextBlock Label { get; init; }
+ public required Ellipse Badge { get; init; }
+ public required ChatTabView View { get; init; }
+
+ /// Model to select once this tab's harness is initialized — set only for
+ /// tabs recreated from a saved workspace. Best-effort: unavailable model = default.
+ public string? RestoreModel { get; init; }
+ }
+
+ private readonly List _tabs = new();
+ private ChatTabEntry? _selected;
+ private ChatTabEntry? _pendingApprovalTab;
+ private bool _approvalToastDismissed;
+
+ ///
+ /// The agent everything else acts on: Esc, the Settings page, the MCP page. Stays put while
+ /// you're looking at Settings — that's what makes "these settings belong to Agent 2" true.
+ ///
+ private ChatTabView? ActiveChat => _selected?.View;
+
+ private void AddTab_Click(object sender, RoutedEventArgs e)
+ {
+ var entry = CreateChatTab();
+ _ = entry.View.InitializeAsync();
+ SaveWorkspace();
+ }
+
+ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = null, string? restoreModel = null, string? persistKey = null)
+ {
+ var session = _sessions.CreateSession(projectRoot, persistKey);
+ if (!string.IsNullOrWhiteSpace(title)) session.Title = title;
+ var view = new ChatTabView(this, session, _html) { Visibility = Visibility.Collapsed };
+
+ view.SetupRequested += () => SwitchPage("settings");
+ view.McpEditorRequested += name =>
+ {
+ SwitchPage("mcp");
+ OpenMcpEditor(name);
+ };
+ view.ClipboardCopyRequested += CopyToClipboard;
+ view.ExitRequested += Close;
+ view.ApprovalStateChanged += _ => RefreshTabStrip();
+ view.HeaderChanged += v =>
+ {
+ var tab = _tabs.FirstOrDefault(t => ReferenceEquals(t.View, v));
+ if (tab != null) tab.Label.Text = v.Session.Title;
+ SaveWorkspace(); // renames, folder switches, and model switches all land here
+ };
+
+ TabHost.Children.Add(view);
+
+ var (header, label, badge) = BuildTabHeader(session.Title);
+ var entry = new ChatTabEntry { Header = header, Label = label, Badge = badge, View = view, RestoreModel = restoreModel };
+ _tabs.Add(entry);
+ TabStrip.Children.Add(header);
+ WireHeader(entry);
+
+ SelectTab(entry);
+ if (_snapshotsPanelOpen) PopulateSnapshots(); // an agent exists now → re-enable Import
+ if (HasComparePair) RefreshSplitCombos(); // include the new agent in the pane pickers
+ return entry;
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Terminal.cs b/src/MandoCode.Desktop/MainWindow.Terminal.cs
new file mode 100644
index 0000000..8ae9d72
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Terminal.cs
@@ -0,0 +1,245 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Integrated terminal (VS-style sliding shell panel)
+ // ============================================================
+
+ private bool _terminalOpen;
+ private bool _terminalMaximized;
+ private double _savedTerminalHeight; // px — the user's last dragged size, restored on reopen
+ private double _preMaxHeight; // px — height to restore to when un-maximizing
+ private Microsoft.UI.Dispatching.DispatcherQueueTimer? _termAnim;
+ private Controls.TerminalPanel? _terminal; // created lazily on first open
+
+ // Maximized terminal leaves ~5% at the top (just the agent tab strip peeking through).
+ private double MaxTerminalHeight() => Math.Max(160, ContentColumnGrid.ActualHeight * 0.95);
+
+ ///
+ /// Builds the terminal panel the first time it's needed and drops it into Grid.Row 3 of the
+ /// content column. Deferred so no WebView2 or shell process is created until the user opens a
+ /// terminal.
+ ///
+ private Controls.TerminalPanel EnsureTerminal()
+ {
+ if (_terminal != null) return _terminal;
+
+ _terminal = new Controls.TerminalPanel { Visibility = Visibility.Collapsed };
+ Grid.SetRow(_terminal, 3);
+ ContentColumnGrid.Children.Add(_terminal);
+
+ // Each new shell opens in whichever agent tab is active at the time.
+ _terminal.WorkingDirectoryProvider = () => ActiveChat?.Session.ProjectRoot.ProjectRoot;
+ _terminal.CloseRequested += (_, _) => CloseTerminalPanel();
+ _terminal.MaximizeRequested += (_, _) => ToggleMaximizeTerminal();
+ return _terminal;
+ }
+
+ private void NavTerminal_Click(object sender, RoutedEventArgs e) => ToggleTerminal();
+
+ private void ToggleTerminal()
+ {
+ if (_terminalOpen) CloseTerminalPanel();
+ else OpenTerminalPanel();
+ }
+
+ private void OpenTerminalPanel()
+ {
+ var term = EnsureTerminal();
+ if (_terminalOpen) { term.FocusActive(); return; }
+ _terminalOpen = true;
+ RefreshNavIcons();
+
+ term.Visibility = Visibility.Visible;
+ TerminalSplitter.Visibility = Visibility.Visible;
+ term.EnsureStartedAsync();
+
+ double target = _savedTerminalHeight > 0 ? _savedTerminalHeight : DefaultTerminalHeight();
+ AnimateTerminalHeight(target, onDone: () => term.Refit());
+ }
+
+ private void CloseTerminalPanel()
+ {
+ if (!_terminalOpen) return;
+ _terminalOpen = false;
+ RefreshNavIcons();
+
+ double current = TerminalRow.Height.Value;
+ if (current > 40) _savedTerminalHeight = current; // remember size for next time
+
+ AnimateTerminalHeight(0, onDone: () =>
+ {
+ if (_terminal != null) _terminal.Visibility = Visibility.Collapsed;
+ TerminalSplitter.Visibility = Visibility.Collapsed;
+ ActiveChat?.FocusInput();
+ });
+ }
+
+ /// Expand the terminal to ~95% of the window (5% left at top), or restore its prior size.
+ private void ToggleMaximizeTerminal()
+ {
+ if (!_terminalOpen) { OpenTerminalPanel(); return; } // first open lands at the default size
+
+ if (_terminalMaximized)
+ {
+ _terminalMaximized = false;
+ double restore = _preMaxHeight > 40 ? _preMaxHeight : DefaultTerminalHeight();
+ AnimateTerminalHeight(restore, onDone: () => _terminal?.Refit());
+ }
+ else
+ {
+ _terminalMaximized = true;
+ _preMaxHeight = TerminalRow.Height.Value;
+ AnimateTerminalHeight(MaxTerminalHeight(), onDone: () => _terminal?.Refit());
+ }
+ _terminal?.SetMaximized(_terminalMaximized);
+ }
+
+ private double DefaultTerminalHeight()
+ {
+ double h = ContentColumnGrid.ActualHeight;
+ if (h <= 0) h = 800;
+ return Math.Clamp(h * 0.30, 140, h * 0.7);
+ }
+
+ ///
+ /// Slides the terminal row to px over ~160ms. A short,
+ /// self-terminating step timer (never an indefinite animation — see the WebView
+ /// repaint history in project memory).
+ ///
+ private void AnimateTerminalHeight(double target, Action? onDone)
+ {
+ _termAnim?.Stop();
+
+ double start = TerminalRow.Height.Value;
+ if (Math.Abs(target - start) < 0.5)
+ {
+ TerminalRow.Height = new GridLength(target);
+ onDone?.Invoke();
+ return;
+ }
+
+ var timer = _dispatcher.CreateTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(15);
+ var sw = Stopwatch.StartNew();
+ const double durationMs = 160;
+
+ timer.Tick += (_, _) =>
+ {
+ double t = Math.Min(1.0, sw.Elapsed.TotalMilliseconds / durationMs);
+ double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
+ TerminalRow.Height = new GridLength(Math.Max(0, start + (target - start) * eased));
+ if (t >= 1.0)
+ {
+ timer.Stop();
+ TerminalRow.Height = new GridLength(target);
+ onDone?.Invoke();
+ }
+ };
+ _termAnim = timer;
+ timer.Start();
+ }
+
+ private bool _draggingSplitter;
+ private double _dragStartHeight;
+ private double _dragStartY;
+
+ private void TerminalSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ _draggingSplitter = true;
+ _dragStartHeight = TerminalRow.Height.Value;
+ _dragStartY = e.GetCurrentPoint(Root).Position.Y; // Root frame — stable as the grip moves
+ ((UIElement)sender).CapturePointer(e.Pointer);
+ }
+
+ private void TerminalSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingSplitter) return;
+ // Dragging up grows the terminal; down shrinks it. Ceiling is the maximized height (~95%).
+ double delta = e.GetCurrentPoint(Root).Position.Y - _dragStartY;
+ double next = Math.Clamp(_dragStartHeight - delta, 80, MaxTerminalHeight());
+ TerminalRow.Height = new GridLength(next);
+ }
+
+ private void TerminalSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingSplitter) return;
+ _draggingSplitter = false;
+ ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+
+ _savedTerminalHeight = TerminalRow.Height.Value;
+ // Keep the maximize button's glyph honest: dragging near the top counts as maximized.
+ _terminalMaximized = TerminalRow.Height.Value >= ContentColumnGrid.ActualHeight * 0.9;
+ if (!_terminalMaximized) _preMaxHeight = TerminalRow.Height.Value;
+ _terminal?.SetMaximized(_terminalMaximized);
+ _terminal?.Refit();
+ }
+
+ private bool _initialized;
+
+ private void Root_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (_initialized) return;
+ _initialized = true;
+ // Restored workspaces can open with several tabs — initialize them all (each owns
+ // its WebView2 + harness, same cost as if the user had opened them by hand).
+ foreach (var entry in _tabs) _ = InitTabAsync(entry);
+ InitBgPreview();
+
+ // Both stores load from disk at construction; reflect their counts on the rail at launch,
+ // before the user opens either panel.
+ RefreshSnapshotsBadge();
+ RefreshHistoryBadge();
+ }
+
+ private async Task InitTabAsync(ChatTabEntry entry)
+ {
+ await entry.View.InitializeAsync();
+ // Best-effort per-tab model restore: if the saved model is gone (Ollama not running,
+ // cloud model renamed), the tab simply keeps the default and says so in its header.
+ var desired = entry.RestoreModel;
+ if (!string.IsNullOrEmpty(desired) && desired != entry.View.Session.Controller.ModelName)
+ {
+ await Task.Run(() => entry.View.Session.Controller.SelectModelAsync(desired));
+ entry.View.UpdateHeader();
+ }
+
+ // Memory comes back only after the model has settled — selecting a model clears
+ // history, so this order is what keeps the restored memory alive.
+ await entry.View.RestoreConversationMemoryAsync();
+ }
+
+ /// Writes the current workspace shape (tabs + active) to disk. Called on close
+ /// and after any structural change, so even a crash loses at most the latest tweak.
+ private void SaveWorkspace()
+ {
+ var tabs = _tabs.Select(t => new WorkspaceTabState(
+ t.View.Session.Title,
+ t.View.Session.ProjectRoot.ProjectRoot,
+ t.View.Session.Controller.ModelName,
+ t.View.Session.PersistKey)).ToList();
+ var active = _selected == null ? 0 : Math.Max(0, _tabs.IndexOf(_selected));
+ WorkspaceState.Save(new WorkspaceShape(tabs, active));
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
new file mode 100644
index 0000000..0c3ed99
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
@@ -0,0 +1,145 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+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;
+
+namespace MandoCode.Desktop;
+
+/// Row model for the slash-command suggestions list.
+public sealed class CommandSuggestion
+{
+ public string Command { get; init; } = "";
+ public string Description { get; init; } = "";
+
+ /// What accepting the row inserts, when that differs from
+ /// (e.g. the ":fire:" row inserts 🔥). Null means insert the command itself.
+ public string? InsertText { get; init; }
+}
+
+/// Row model for the snapshot summarizer dropdown — a model name plus whether it's a cloud
+/// model (which may spend tokens) or a local one (free).
+public sealed record ModelChoice(string Name, bool IsCloud)
+{
+ public string Tag => IsCloud ? "cloud · uses tokens" : "local · free";
+}
+
+/// A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
+/// so a can group
+/// on it directly — the ListView's group-header template binds to and
+/// .
+public sealed class SnapshotGroup : List
+{
+ public SnapshotGroup(string project, IEnumerable items) : base(items)
+ => Project = project;
+
+ public string Project { get; }
+
+ /// Whether the group's Expander is open. Set when the groups are rebuilt (from the
+ /// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
+ /// expand/collapse events keep the remembered set current thereafter.
+ public bool IsExpanded { get; set; } = true;
+}
+
+/// A project's closed conversations, as one collapsible group in the History panel —
+/// the archive twin of .
+public sealed class HistoryGroup : List
+{
+ public HistoryGroup(string project, IEnumerable items) : base(items)
+ => Project = project;
+
+ public string Project { get; }
+
+ public bool IsExpanded { get; set; } = true;
+}
+
+/// Row model for diff lines shown in the approval overlay.
+public sealed class DiffLineVm
+{
+ public string Text { get; init; } = "";
+ public SolidColorBrush Brush { get; init; } = new(Colors.Gray);
+}
+
+/// Row model for the MCP servers page.
+public sealed class McpRow
+{
+ public string Name { get; init; } = "";
+ public string Transport { get; init; } = "";
+ public string Status { get; init; } = "";
+ public SolidColorBrush StatusBrush { get; init; } = new(Colors.Gray);
+ /// Per-server on/off (the config's Disabled flag, inverted). Shared by every agent.
+ public bool Enabled { get; init; }
+}
+
+/// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a
+/// CollectionViewSource can group on it directly; the header binds to .
+public sealed class SkillRowGroup : List
+{
+ public string Key { get; }
+ public SkillRowGroup(string key, IEnumerable items) : base(items) => Key = key;
+}
+
+/// A section of the MCP servers list (e.g. "Disabled (3)").
+public sealed class McpRowGroup : List
+{
+ public string Key { get; }
+ public McpRowGroup(string key, IEnumerable items) : base(items) => Key = key;
+}
+
+/// Row model for the global-skills page. FolderPath rides along so per-row actions
+/// (the enable toggle) can act on the right skill without leaning on list selection.
+public sealed class SkillRow
+{
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+ public string Body { get; init; } = "";
+ public string FolderPath { get; init; } = "";
+ public bool Enabled { get; init; }
+
+ // Size of the instructions body — what gets injected into the prompt on load, so it's the cost
+ // that spins a local model up. ~4 chars/token is the usual rough estimate.
+ public int ApproxTokens => (Body.Length + 3) / 4;
+ public bool IsLarge => ApproxTokens >= 2000;
+ public string SizeLabel =>
+ (ApproxTokens >= 1000 ? $"≈{ApproxTokens / 1000.0:0.0}k tok" : $"≈{ApproxTokens} tok")
+ + (IsLarge ? " · large" : "");
+ public SolidColorBrush SizeBrush =>
+ new(ThemeManager.C(IsLarge ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
+}
+
+/// Chip model for the MCP editor's tool preview (test results).
+public sealed class ToolChip
+{
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+}
+
+/// Row model for the Appearance tab's theme picker — each card is drawn in
+/// its own theme's colors so the list doubles as a preview.
+public sealed class ThemeVm
+{
+ public required UiTheme Theme { get; init; }
+ public string Name => Theme.Name;
+ public string Description => Theme.Description;
+ public SolidColorBrush BgBrush => new(ThemeManager.C(Theme.Background));
+ public SolidColorBrush EdgeBrush => new(ThemeManager.C(Theme.Border));
+ public SolidColorBrush FgBrush => new(ThemeManager.C(Theme.Text));
+ public SolidColorBrush DimBrush => new(ThemeManager.C(Theme.Dim));
+ public SolidColorBrush AccentBrush => new(ThemeManager.C(Theme.Accent));
+ public SolidColorBrush GoldBrush => new(ThemeManager.C(Theme.Gold));
+ public SolidColorBrush SkyBrush => new(ThemeManager.C(Theme.Sky));
+ public SolidColorBrush GreenBrush => new(ThemeManager.C(Theme.Green));
+}
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 53342b3..444dcc3 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -1228,7 +1228,7 @@
-
Row model for the slash-command suggestions list.
-public sealed class CommandSuggestion
-{
- public string Command { get; init; } = "";
- public string Description { get; init; } = "";
-
- /// What accepting the row inserts, when that differs from
- /// (e.g. the ":fire:" row inserts 🔥). Null means insert the command itself.
- public string? InsertText { get; init; }
-}
-
-/// Row model for the snapshot summarizer dropdown — a model name plus whether it's a cloud
-/// model (which may spend tokens) or a local one (free).
-public sealed record ModelChoice(string Name, bool IsCloud)
-{
- public string Tag => IsCloud ? "cloud · uses tokens" : "local · free";
-}
-
-/// A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
-/// so a can group
-/// on it directly — the ListView's group-header template binds to and
-/// .
-public sealed class SnapshotGroup : List
-{
- public SnapshotGroup(string project, IEnumerable items) : base(items)
- => Project = project;
-
- public string Project { get; }
-
- /// Whether the group's Expander is open. Set when the groups are rebuilt (from the
- /// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
- /// expand/collapse events keep the remembered set current thereafter.
- public bool IsExpanded { get; set; } = true;
-}
-
-/// A project's closed conversations, as one collapsible group in the History panel —
-/// the archive twin of .
-public sealed class HistoryGroup : List
-{
- public HistoryGroup(string project, IEnumerable items) : base(items)
- => Project = project;
-
- public string Project { get; }
-
- public bool IsExpanded { get; set; } = true;
-}
-
-/// Row model for diff lines shown in the approval overlay.
-public sealed class DiffLineVm
-{
- public string Text { get; init; } = "";
- public SolidColorBrush Brush { get; init; } = new(Colors.Gray);
-}
-
-/// Row model for the MCP servers page.
-public sealed class McpRow
-{
- public string Name { get; init; } = "";
- public string Transport { get; init; } = "";
- public string Status { get; init; } = "";
- public SolidColorBrush StatusBrush { get; init; } = new(Colors.Gray);
- /// Per-server on/off (the config's Disabled flag, inverted). Shared by every agent.
- public bool Enabled { get; init; }
-}
-
-/// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a
-/// CollectionViewSource can group on it directly; the header binds to .
-public sealed class SkillRowGroup : List
-{
- public string Key { get; }
- public SkillRowGroup(string key, IEnumerable items) : base(items) => Key = key;
-}
-
-/// A section of the MCP servers list (e.g. "Disabled (3)").
-public sealed class McpRowGroup : List
-{
- public string Key { get; }
- public McpRowGroup(string key, IEnumerable items) : base(items) => Key = key;
-}
-
-/// Row model for the global-skills page. FolderPath rides along so per-row actions
-/// (the enable toggle) can act on the right skill without leaning on list selection.
-public sealed class SkillRow
-{
- public string Name { get; init; } = "";
- public string Description { get; init; } = "";
- public string Body { get; init; } = "";
- public string FolderPath { get; init; } = "";
- public bool Enabled { get; init; }
-
- // Size of the instructions body — what gets injected into the prompt on load, so it's the cost
- // that spins a local model up. ~4 chars/token is the usual rough estimate.
- public int ApproxTokens => (Body.Length + 3) / 4;
- public bool IsLarge => ApproxTokens >= 2000;
- public string SizeLabel =>
- (ApproxTokens >= 1000 ? $"≈{ApproxTokens / 1000.0:0.0}k tok" : $"≈{ApproxTokens} tok")
- + (IsLarge ? " · large" : "");
- public SolidColorBrush SizeBrush =>
- new(ThemeManager.C(IsLarge ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
-}
-
-/// Chip model for the MCP editor's tool preview (test results).
-public sealed class ToolChip
-{
- public string Name { get; init; } = "";
- public string Description { get; init; } = "";
-}
-
-/// Row model for the Appearance tab's theme picker — each card is drawn in
-/// its own theme's colors so the list doubles as a preview.
-public sealed class ThemeVm
-{
- public required UiTheme Theme { get; init; }
- public string Name => Theme.Name;
- public string Description => Theme.Description;
- public SolidColorBrush BgBrush => new(ThemeManager.C(Theme.Background));
- public SolidColorBrush EdgeBrush => new(ThemeManager.C(Theme.Border));
- public SolidColorBrush FgBrush => new(ThemeManager.C(Theme.Text));
- public SolidColorBrush DimBrush => new(ThemeManager.C(Theme.Dim));
- public SolidColorBrush AccentBrush => new(ThemeManager.C(Theme.Accent));
- public SolidColorBrush GoldBrush => new(ThemeManager.C(Theme.Gold));
- public SolidColorBrush SkyBrush => new(ThemeManager.C(Theme.Sky));
- public SolidColorBrush GreenBrush => new(ThemeManager.C(Theme.Green));
-}
-
public sealed partial class MainWindow : Window
{
private readonly SessionManager _sessions;
@@ -274,2610 +149,4 @@ private void MainWindow_Closed(object sender, WindowEventArgs args)
catch { /* nothing playing, or already disposed */ }
}
- // ============================================================
- // Integrated terminal (VS-style sliding shell panel)
- // ============================================================
-
- private bool _terminalOpen;
- private bool _terminalMaximized;
- private double _savedTerminalHeight; // px — the user's last dragged size, restored on reopen
- private double _preMaxHeight; // px — height to restore to when un-maximizing
- private Microsoft.UI.Dispatching.DispatcherQueueTimer? _termAnim;
- private Controls.TerminalPanel? _terminal; // created lazily on first open
-
- // Maximized terminal leaves ~5% at the top (just the agent tab strip peeking through).
- private double MaxTerminalHeight() => Math.Max(160, ContentColumnGrid.ActualHeight * 0.95);
-
- ///
- /// Builds the terminal panel the first time it's needed and drops it into Grid.Row 3 of the
- /// content column. Deferred so no WebView2 or shell process is created until the user opens a
- /// terminal.
- ///
- private Controls.TerminalPanel EnsureTerminal()
- {
- if (_terminal != null) return _terminal;
-
- _terminal = new Controls.TerminalPanel { Visibility = Visibility.Collapsed };
- Grid.SetRow(_terminal, 3);
- ContentColumnGrid.Children.Add(_terminal);
-
- // Each new shell opens in whichever agent tab is active at the time.
- _terminal.WorkingDirectoryProvider = () => ActiveChat?.Session.ProjectRoot.ProjectRoot;
- _terminal.CloseRequested += (_, _) => CloseTerminalPanel();
- _terminal.MaximizeRequested += (_, _) => ToggleMaximizeTerminal();
- return _terminal;
- }
-
- private void NavTerminal_Click(object sender, RoutedEventArgs e) => ToggleTerminal();
-
- private void ToggleTerminal()
- {
- if (_terminalOpen) CloseTerminalPanel();
- else OpenTerminalPanel();
- }
-
- private void OpenTerminalPanel()
- {
- var term = EnsureTerminal();
- if (_terminalOpen) { term.FocusActive(); return; }
- _terminalOpen = true;
- RefreshNavIcons();
-
- term.Visibility = Visibility.Visible;
- TerminalSplitter.Visibility = Visibility.Visible;
- term.EnsureStartedAsync();
-
- double target = _savedTerminalHeight > 0 ? _savedTerminalHeight : DefaultTerminalHeight();
- AnimateTerminalHeight(target, onDone: () => term.Refit());
- }
-
- private void CloseTerminalPanel()
- {
- if (!_terminalOpen) return;
- _terminalOpen = false;
- RefreshNavIcons();
-
- double current = TerminalRow.Height.Value;
- if (current > 40) _savedTerminalHeight = current; // remember size for next time
-
- AnimateTerminalHeight(0, onDone: () =>
- {
- if (_terminal != null) _terminal.Visibility = Visibility.Collapsed;
- TerminalSplitter.Visibility = Visibility.Collapsed;
- ActiveChat?.FocusInput();
- });
- }
-
- /// Expand the terminal to ~95% of the window (5% left at top), or restore its prior size.
- private void ToggleMaximizeTerminal()
- {
- if (!_terminalOpen) { OpenTerminalPanel(); return; } // first open lands at the default size
-
- if (_terminalMaximized)
- {
- _terminalMaximized = false;
- double restore = _preMaxHeight > 40 ? _preMaxHeight : DefaultTerminalHeight();
- AnimateTerminalHeight(restore, onDone: () => _terminal?.Refit());
- }
- else
- {
- _terminalMaximized = true;
- _preMaxHeight = TerminalRow.Height.Value;
- AnimateTerminalHeight(MaxTerminalHeight(), onDone: () => _terminal?.Refit());
- }
- _terminal?.SetMaximized(_terminalMaximized);
- }
-
- private double DefaultTerminalHeight()
- {
- double h = ContentColumnGrid.ActualHeight;
- if (h <= 0) h = 800;
- return Math.Clamp(h * 0.30, 140, h * 0.7);
- }
-
- ///
- /// Slides the terminal row to px over ~160ms. A short,
- /// self-terminating step timer (never an indefinite animation — see the WebView
- /// repaint history in project memory).
- ///
- private void AnimateTerminalHeight(double target, Action? onDone)
- {
- _termAnim?.Stop();
-
- double start = TerminalRow.Height.Value;
- if (Math.Abs(target - start) < 0.5)
- {
- TerminalRow.Height = new GridLength(target);
- onDone?.Invoke();
- return;
- }
-
- var timer = _dispatcher.CreateTimer();
- timer.Interval = TimeSpan.FromMilliseconds(15);
- var sw = Stopwatch.StartNew();
- const double durationMs = 160;
-
- timer.Tick += (_, _) =>
- {
- double t = Math.Min(1.0, sw.Elapsed.TotalMilliseconds / durationMs);
- double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
- TerminalRow.Height = new GridLength(Math.Max(0, start + (target - start) * eased));
- if (t >= 1.0)
- {
- timer.Stop();
- TerminalRow.Height = new GridLength(target);
- onDone?.Invoke();
- }
- };
- _termAnim = timer;
- timer.Start();
- }
-
- private bool _draggingSplitter;
- private double _dragStartHeight;
- private double _dragStartY;
-
- private void TerminalSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
- {
- _draggingSplitter = true;
- _dragStartHeight = TerminalRow.Height.Value;
- _dragStartY = e.GetCurrentPoint(Root).Position.Y; // Root frame — stable as the grip moves
- ((UIElement)sender).CapturePointer(e.Pointer);
- }
-
- private void TerminalSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingSplitter) return;
- // Dragging up grows the terminal; down shrinks it. Ceiling is the maximized height (~95%).
- double delta = e.GetCurrentPoint(Root).Position.Y - _dragStartY;
- double next = Math.Clamp(_dragStartHeight - delta, 80, MaxTerminalHeight());
- TerminalRow.Height = new GridLength(next);
- }
-
- private void TerminalSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingSplitter) return;
- _draggingSplitter = false;
- ((UIElement)sender).ReleasePointerCapture(e.Pointer);
-
- _savedTerminalHeight = TerminalRow.Height.Value;
- // Keep the maximize button's glyph honest: dragging near the top counts as maximized.
- _terminalMaximized = TerminalRow.Height.Value >= ContentColumnGrid.ActualHeight * 0.9;
- if (!_terminalMaximized) _preMaxHeight = TerminalRow.Height.Value;
- _terminal?.SetMaximized(_terminalMaximized);
- _terminal?.Refit();
- }
-
- private bool _initialized;
-
- private void Root_Loaded(object sender, RoutedEventArgs e)
- {
- if (_initialized) return;
- _initialized = true;
- // Restored workspaces can open with several tabs — initialize them all (each owns
- // its WebView2 + harness, same cost as if the user had opened them by hand).
- foreach (var entry in _tabs) _ = InitTabAsync(entry);
- InitBgPreview();
-
- // Both stores load from disk at construction; reflect their counts on the rail at launch,
- // before the user opens either panel.
- RefreshSnapshotsBadge();
- RefreshHistoryBadge();
- }
-
- private async Task InitTabAsync(ChatTabEntry entry)
- {
- await entry.View.InitializeAsync();
- // Best-effort per-tab model restore: if the saved model is gone (Ollama not running,
- // cloud model renamed), the tab simply keeps the default and says so in its header.
- var desired = entry.RestoreModel;
- if (!string.IsNullOrEmpty(desired) && desired != entry.View.Session.Controller.ModelName)
- {
- await Task.Run(() => entry.View.Session.Controller.SelectModelAsync(desired));
- entry.View.UpdateHeader();
- }
-
- // Memory comes back only after the model has settled — selecting a model clears
- // history, so this order is what keeps the restored memory alive.
- await entry.View.RestoreConversationMemoryAsync();
- }
-
- /// Writes the current workspace shape (tabs + active) to disk. Called on close
- /// and after any structural change, so even a crash loses at most the latest tweak.
- private void SaveWorkspace()
- {
- var tabs = _tabs.Select(t => new WorkspaceTabState(
- t.View.Session.Title,
- t.View.Session.ProjectRoot.ProjectRoot,
- t.View.Session.Controller.ModelName,
- t.View.Session.PersistKey)).ToList();
- var active = _selected == null ? 0 : Math.Max(0, _tabs.IndexOf(_selected));
- WorkspaceState.Save(new WorkspaceShape(tabs, active));
- }
-
- // ============================================================
- // Appearance-page live preview — a real miniature transcript
- // ============================================================
- // Same shell + theme script as the tabs, so it shows EXACTLY what they show (background
- // image, boxed messages, E-Ink dithering, W98 chrome). Failures never break settings —
- // the preview is a luxury.
-
- private bool _previewWebReady;
-
- private async void InitBgPreview()
- {
- try
- {
- await BgPreviewWeb.EnsureCoreWebView2Async();
- var core = BgPreviewWeb.CoreWebView2;
- core.Settings.AreDefaultContextMenusEnabled = false;
-
- // Same virtual hosts the tabs map: assets (highlight.js) + userdata (bg image).
- try
- {
- core.SetVirtualHostNameToFolderMapping(
- "mandocode.assets",
- System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "web"),
- Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
- }
- catch { }
- try
- {
- Directory.CreateDirectory(ThemeManager.UserDataFolder);
- core.SetVirtualHostNameToFolderMapping(
- "mandocode.userdata", ThemeManager.UserDataFolder,
- Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
- }
- catch { }
-
- core.NavigationCompleted += (_, _) =>
- {
- if (_previewWebReady) return;
- _previewWebReady = true;
- _ = SeedBgPreviewAsync();
- };
- core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
- }
- catch { /* no preview — settings still fully functional */ }
- }
-
- private async Task SeedBgPreviewAsync()
- {
- try
- {
- var blocks =
- _html.UserEcho("how does this look?") +
- _html.AssistantCard(
- "Like this — the image fades, the text never does.\n\n" +
- "Inline `code` and a block, to judge every surface:\n\n" +
- "```csharp\nvar vibe = \"immaculate\";\n```");
- await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
- "window.__append(" + JsonSerializer.Serialize(blocks) + ");");
- await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
- ThemeManager.BuildTranscriptScript(ThemeManager.Current));
- }
- catch { }
- }
-
- private void OnUi(Action action)
- {
- if (_dispatcher.HasThreadAccess) action();
- else _dispatcher.TryEnqueue(() => action());
- }
-
- private void Root_KeyDown(object sender, KeyRoutedEventArgs e)
- {
- if (e.Key == VirtualKey.Escape) { ActiveChat?.HandleEscape(); return; }
-
- // Ctrl+` toggles the terminal; Ctrl+Shift+` opens a new shell tab (VS Code parity).
- // 192 == VK_OEM_3 (backtick/tilde). Handled here rather than via a KeyboardAccelerator:
- // WinUI fast-fails natively when an accelerator is registered on an OEM key.
- if (e.Key == (VirtualKey)192 && IsDown(VirtualKey.Control))
- {
- e.Handled = true;
- if (IsDown(VirtualKey.Shift)) { OpenTerminalPanel(); _terminal!.NewTerminalTab(); }
- else ToggleTerminal();
- }
- }
-
- private static bool IsDown(VirtualKey key) =>
- Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(key)
- .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
-
- private void ApplyThemeToAllTabs()
- {
- foreach (var tab in _tabs) tab.View.ApplyTheme();
- // The appearance preview is a transcript too — it re-themes with everyone else.
- if (_previewWebReady && BgPreviewWeb.CoreWebView2 != null)
- _ = BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
- ThemeManager.BuildTranscriptScript(ThemeManager.Current));
- }
-
- private void CopyToClipboard(string text)
- {
- var package = new DataPackage();
- package.SetText(text);
- Clipboard.SetContent(package);
- }
-
- // ============================================================
- // Tabs
- // ============================================================
-
- /// One independent agent: its strip header and its chat surface.
- private sealed class ChatTabEntry
- {
- public required Border Header { get; init; }
- public required TextBlock Label { get; init; }
- public required Ellipse Badge { get; init; }
- public required ChatTabView View { get; init; }
-
- /// Model to select once this tab's harness is initialized — set only for
- /// tabs recreated from a saved workspace. Best-effort: unavailable model = default.
- public string? RestoreModel { get; init; }
- }
-
- private readonly List _tabs = new();
- private ChatTabEntry? _selected;
- private ChatTabEntry? _pendingApprovalTab;
- private bool _approvalToastDismissed;
-
- ///
- /// The agent everything else acts on: Esc, the Settings page, the MCP page. Stays put while
- /// you're looking at Settings — that's what makes "these settings belong to Agent 2" true.
- ///
- private ChatTabView? ActiveChat => _selected?.View;
-
- private void AddTab_Click(object sender, RoutedEventArgs e)
- {
- var entry = CreateChatTab();
- _ = entry.View.InitializeAsync();
- SaveWorkspace();
- }
-
- private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = null, string? restoreModel = null, string? persistKey = null)
- {
- var session = _sessions.CreateSession(projectRoot, persistKey);
- if (!string.IsNullOrWhiteSpace(title)) session.Title = title;
- var view = new ChatTabView(this, session, _html) { Visibility = Visibility.Collapsed };
-
- view.SetupRequested += () => SwitchPage("settings");
- view.McpEditorRequested += name =>
- {
- SwitchPage("mcp");
- OpenMcpEditor(name);
- };
- view.ClipboardCopyRequested += CopyToClipboard;
- view.ExitRequested += Close;
- view.ApprovalStateChanged += _ => RefreshTabStrip();
- view.HeaderChanged += v =>
- {
- var tab = _tabs.FirstOrDefault(t => ReferenceEquals(t.View, v));
- if (tab != null) tab.Label.Text = v.Session.Title;
- SaveWorkspace(); // renames, folder switches, and model switches all land here
- };
-
- TabHost.Children.Add(view);
-
- var (header, label, badge) = BuildTabHeader(session.Title);
- var entry = new ChatTabEntry { Header = header, Label = label, Badge = badge, View = view, RestoreModel = restoreModel };
- _tabs.Add(entry);
- TabStrip.Children.Add(header);
- WireHeader(entry);
-
- SelectTab(entry);
- if (_snapshotsPanelOpen) PopulateSnapshots(); // an agent exists now → re-enable Import
- if (HasComparePair) RefreshSplitCombos(); // include the new agent in the pane pickers
- return entry;
- }
-
- // ============================================================
- // Sidebar navigation — Settings and MCP are full-screen pages, not tabs. They act on
- // whichever agent is selected, so switching pages never changes which agent that is.
- // ============================================================
-
- private string _currentPage = "chat";
-
- private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat");
-
- // Settings/MCP act as toggles: clicking the one you're already on closes it and returns to the
- // last active agent, rather than reloading the page in place.
- private void NavSettings_Click(object sender, RoutedEventArgs e)
- => SwitchPage(_currentPage == "settings" ? "chat" : "settings");
- private void NavMcp_Click(object sender, RoutedEventArgs e)
- => SwitchPage(_currentPage == "mcp" ? "chat" : "mcp");
- private void NavSkills_Click(object sender, RoutedEventArgs e)
- => SwitchPage(_currentPage == "skills" ? "chat" : "skills");
- private void NavAppearance_Click(object sender, RoutedEventArgs e)
- => SwitchPage(_currentPage == "appearance" ? "chat" : "appearance");
-
- private void SwitchPage(string page)
- {
- // Settings and MCP act on the selected agent — with none open there's nothing to edit, so
- // fall back to the (empty) chat. Skills and Appearance are app-global and stay reachable.
- if ((page == "settings" || page == "mcp") && _sessions.Active == null) page = "chat";
-
- _currentPage = page;
- var showingChat = page == "chat";
-
- SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed;
- McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed;
- SkillsPage.Visibility = page == "skills" ? Visibility.Visible : Visibility.Collapsed;
- AppearancePage.Visibility = page == "appearance" ? Visibility.Visible : Visibility.Collapsed;
-
- // Glide the full-screen page in from the rail side (translate + fade). Both run on the
- // composition thread, so the whole page slides smoothly regardless of how much it holds.
- if (page == "settings") SlideInPage(SettingsPage, SettingsPageTransform);
- else if (page == "mcp") SlideInPage(McpPage, McpPageTransform);
- else if (page == "skills") SlideInPage(SkillsPage, SkillsPageTransform);
- else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform);
-
- // Every agent view stays loaded; only the visible one(s) show, and only on the chat page.
- // Collapsing rather than removing is what keeps each WebView2's transcript alive. In split
- // mode two views show at once (the compare pair, _compareA left / _compareB right).
- ApplyPaneLayout();
-
- // The empty-state background shows only on the chat page with no agents left.
- EmptyAgentsState.Visibility = showingChat && _tabs.Count == 0
- ? Visibility.Visible : Visibility.Collapsed;
- _ = RefreshEmptyBackgroundAsync();
-
- RefreshNavIcons();
- // Re-evaluate the approval toast for the new page — leaving the chat can newly "hide" the
- // selected agent's approval, which should now raise the toast (and returning clears it).
- RefreshTabStrip();
-
- switch (page)
- {
- case "settings":
- LoadSettings();
- _ = RefreshModelListAsync();
- break;
- case "mcp":
- _ = RefreshMcpListAsync();
- break;
- case "skills":
- RefreshSkillsList();
- break;
- default:
- ActiveChat?.FocusInput();
- break;
- }
- }
-
- /// Slides a full-screen page (Settings/MCP) into view from the rail side, with a short
- /// fade. Translate and Opacity are independent animations, so this stays smooth on the
- /// composition thread no matter how much the page contains.
- private static void SlideInPage(UIElement page, TranslateTransform transform)
- {
- var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
-
- var slide = new DoubleAnimation
- {
- From = -48,
- To = 0,
- Duration = new Duration(TimeSpan.FromMilliseconds(260)),
- EasingFunction = ease,
- };
- Storyboard.SetTarget(slide, transform);
- Storyboard.SetTargetProperty(slide, "X");
-
- var fade = new DoubleAnimation
- {
- From = 0,
- To = 1,
- Duration = new Duration(TimeSpan.FromMilliseconds(200)),
- EasingFunction = ease,
- };
- Storyboard.SetTarget(fade, page);
- Storyboard.SetTargetProperty(fade, "Opacity");
-
- var sb = new Storyboard();
- sb.Children.Add(slide);
- sb.Children.Add(fade);
- sb.Begin();
- }
-
- private void RefreshNavIcons()
- {
- var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
- var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
- var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
-
- // An approval waiting in ANY agent while you're on Settings/MCP: the agents icon goes gold,
- // because from here you can't see which tab is badged.
- var approvalPending = _currentPage != "chat" && _tabs.Any(t => t.View.IsApprovalOpen);
-
- NavChatIcon.Foreground = _currentPage == "chat" ? accent : (approvalPending ? gold : normal);
- NavSettingsIcon.Foreground = _currentPage == "settings" ? accent : normal;
- NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal;
- NavSkillsIcon.Foreground = _currentPage == "skills" ? accent : normal;
- NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal;
- NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal;
- NavHistoryIcon.Foreground = _historyPanelOpen ? accent : normal;
- NavTerminalIcon.Foreground = _terminalOpen ? accent : normal;
-
- // Settings and MCP act on the selected agent — disable them while none is open.
- var hasAgent = _sessions.Active != null;
- NavSettings.IsEnabled = hasAgent;
- NavMcp.IsEnabled = hasAgent;
- ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents");
- }
-
- // ============================================================
- // Snapshots panel — global (the store is app-wide), toggled from the rail. Docked left at
- // ~37% width so the active chat stays visible; Import arms the selected agent's next message.
- // ============================================================
-
- private void NavSnapshots_Click(object sender, RoutedEventArgs e)
- {
- if (_snapshotsPanelOpen) CloseLeftPanel();
- else OpenSnapshots();
- }
-
- private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
-
- private void OpenSnapshots()
- {
- MarkSnapshotsSeen(); // opening the panel IS reading it — clear the unread badge
- PopulateSnapshots();
- ShowLeftPanel(SnapshotsPanel, snapshots: true);
- }
-
- /// Shows one of the two docked panels (Snapshots/History), swapping if the other was
- /// already up (the column stays out — only the contents change) and sliding it in otherwise.
- private void ShowLeftPanel(Border panel, bool snapshots)
- {
- bool wasOpen = _snapshotsPanelOpen || _historyPanelOpen;
- _snapshotsPanelOpen = snapshots;
- _historyPanelOpen = !snapshots;
- SnapshotsPanel.Visibility = snapshots ? Visibility.Visible : Visibility.Collapsed;
- HistoryPanel.Visibility = snapshots ? Visibility.Collapsed : Visibility.Visible;
- RefreshNavIcons();
- if (wasOpen) return; // column already at width — contents swapped, no re-slide
-
- // Target ~37% of the content area (everything right of the 48px rail), matching the old
- // 0.6* / 1* split. Computed in pixels at open time so the tween can drive the column.
- double target = Math.Max(320, (Root.ActualWidth - 48) * 0.375);
- AnimateLeftColumn(target, hideOnDone: null);
- }
-
- private void CloseLeftPanel()
- {
- var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel
- : _historyPanelOpen ? HistoryPanel : null;
- _snapshotsPanelOpen = false;
- _historyPanelOpen = false;
- RefreshNavIcons();
- AnimateLeftColumn(0, hideOnDone: toHide);
- }
-
- /// Tweens the docked column width to with an ease-out curve,
- /// gliding the panel open or closed. Re-entrant: a click mid-slide retargets from the current
- /// width rather than restarting from the edge. , when set, is
- /// collapsed once a close tween lands.
- private void AnimateLeftColumn(double toPx, FrameworkElement? hideOnDone)
- {
- // Drop any in-flight tween so rapid toggles can't stack Rendering handlers.
- if (_snapAnimHandler != null) CompositionTarget.Rendering -= _snapAnimHandler;
-
- _snapAnimFrom = SnapshotsColumn.Width.IsAbsolute ? SnapshotsColumn.Width.Value : 0;
- _snapAnimTo = toPx;
- _snapAnimHide = hideOnDone;
- _snapAnimClock.Restart();
-
- _snapAnimHandler = (_, _) =>
- {
- double t = Math.Clamp(_snapAnimClock.Elapsed.TotalMilliseconds / SnapAnimDurationMs, 0, 1);
- double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
- double w = _snapAnimFrom + (_snapAnimTo - _snapAnimFrom) * eased;
- SnapshotsColumn.Width = new GridLength(w, GridUnitType.Pixel);
-
- if (t >= 1)
- {
- CompositionTarget.Rendering -= _snapAnimHandler;
- _snapAnimHandler = null;
- _snapAnimClock.Stop();
- if (_snapAnimHide != null) _snapAnimHide.Visibility = Visibility.Collapsed;
- }
- };
- CompositionTarget.Rendering += _snapAnimHandler;
- }
-
- private void OnSnapshotsChanged()
- {
- // A change while you're looking at the panel is already seen; otherwise it's a new unread.
- if (_snapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
- else RefreshSnapshotsBadge();
- }
-
- /// Marks every current snapshot as seen (opening the panel, or a change while it's open),
- /// clearing the rail badge. Persisted so the badge doesn't re-light on relaunch.
- private void MarkSnapshotsSeen()
- {
- _snapshotsSeenAt = DateTimeOffset.Now;
- SavePanelState();
- RefreshSnapshotsBadge();
- }
-
- /// Current text in the snapshots search box; empty means "show everything".
- private string _snapshotFilter = "";
-
- /// Project labels whose group is folded shut. Survives repopulation (search, import,
- /// delete) so a collapse the user made doesn't spring back open on the next keystroke.
- private readonly HashSet _collapsedSnapshotGroups = new();
-
- // "Last opened" watermarks — the rail badges show how many snapshots/closed conversations are
- // newer than these, i.e. unread since the last visit. Persisted in panel-state.json.
- private DateTimeOffset? _snapshotsSeenAt;
- private DateTimeOffset? _historySeenAt;
-
- /// Writes both panels' fold state and seen-watermarks to disk (survives relaunch).
- private void SavePanelState() => PanelState.Save(new PanelStateShape(
- _collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
- _snapshotsSeenAt, _historySeenAt));
-
- // The group object is kept in sync (not just the set) so that when the ListView recycles a
- // container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
- private void SnapshotGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
- {
- if (sender.Tag is not SnapshotGroup g) return;
- g.IsExpanded = true;
- _collapsedSnapshotGroups.Remove(g.Project);
- SavePanelState();
- }
-
- private void SnapshotGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
- {
- if (sender.Tag is not SnapshotGroup g) return;
- g.IsExpanded = false;
- _collapsedSnapshotGroups.Add(g.Project);
- SavePanelState();
- }
-
- private void SnapshotsSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
- {
- // Only react to the user typing — not to programmatic Text changes on repopulate.
- if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
- _snapshotFilter = sender.Text?.Trim() ?? "";
- PopulateSnapshots();
- }
-
- private static bool Matches(ContextSnapshot s, string q) =>
- s.DisplayTitle.Contains(q, StringComparison.OrdinalIgnoreCase)
- || s.OriginModel.Contains(q, StringComparison.OrdinalIgnoreCase)
- || s.SummarizerModel.Contains(q, StringComparison.OrdinalIgnoreCase)
- || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
- || (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
-
- private void PopulateSnapshots()
- {
- var all = _snapshotStore.Items; // newest-first copy of the shared store
- var storeEmpty = all.Count == 0;
-
- // The search box only earns its space once there's something to search.
- SnapshotsSearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
-
- // Explain the disabled Import buttons when there's a snapshot but no agent to import into.
- SnapshotsNoAgentNotice.IsOpen = !storeEmpty && _sessions.Active == null;
-
- var q = _snapshotFilter;
- var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
-
- // Group by project, preserving the store's newest-first order within each group and
- // ordering the groups by their most-recent snapshot (so the freshest project leads).
- // Each group carries its remembered expand/collapse state so folding a project sticks
- // across searches and imports (which both rebuild this list).
- var groups = filtered
- .GroupBy(s => s.ProjectLabel)
- .OrderByDescending(g => g.Max(s => s.CapturedAt))
- .Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
- .ToList();
-
- SnapshotsList.ItemsSource = groups;
-
- var nothingToShow = groups.Count == 0;
- SnapshotsEmpty.Text = storeEmpty
- ? "No snapshots yet. When you switch a tab's model — or pick Take snapshot from a tab's ⋯ menu — you'll be offered to save the conversation as a snapshot, summarized by a model you choose."
- : $"No snapshots match “{q}”.";
- SnapshotsEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
- SnapshotsScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
- RefreshSnapshotsBadge();
- }
-
- private void RefreshSnapshotsBadge()
- {
- // Unread = snapshots captured after the last visit. Never-visited (null) counts them all.
- var n = _snapshotsSeenAt is { } seen
- ? _snapshotStore.Items.Count(s => s.CapturedAt > seen)
- : _snapshotStore.Count;
- NavSnapshotsBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
- NavSnapshotsBadgeText.Text = n > 99 ? "99+" : n.ToString();
- }
-
- /// Each Import button disables itself when there's no agent to import into — the action
- /// arms an agent's next message, so it's meaningless with none open. Re-evaluated on load, and the
- /// list is repopulated when the agent count crosses zero (so open buttons refresh too).
- private void SnapshotImport_Loaded(object sender, RoutedEventArgs e)
- {
- if (sender is Button b) b.IsEnabled = _sessions.Active != null;
- }
-
- private void SnapshotImport_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
- var target = _selected?.View;
- if (target == null) return; // no agent open — nothing to import into (button is disabled too)
-
- target.Session.Controller.ImportContext(snap); // arms the active agent's next message
- SwitchPage("chat"); // so the "context armed" note is visible in the active tab
- if (_snapshotsPanelOpen) CloseLeftPanel(); // get out of the way — the chat is where the confirmation shows
- target.FocusInput();
- }
-
- private void SnapshotDelete_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
- _snapshotStore.Remove(snap);
- PopulateSnapshots();
- }
-
- // ============================================================
- // History panel — reopen a closed conversation. Shares the docked column with Snapshots.
- // ============================================================
-
- /// Files a just-closed tab into the archive so it can be reopened later. A session that
- /// never had a real turn is forgotten instead (deleting its files), same as /clear —
- /// there's nothing worth reopening, and an empty row would only be noise.
- private void ArchiveClosedSession(AgentSession session)
- {
- var key = session.PersistKey;
- var turns = ConversationLog.Load(key);
- if (turns.Count == 0)
- {
- TranscriptJournal.Delete(key);
- ConversationLog.Delete(key);
- SessionHistoryStore.Delete(key);
- return;
- }
-
- var preview = turns.FirstOrDefault(t => t.R == "u")?.T?.Trim();
- if (preview is { Length: > 140 }) preview = preview[..140].TrimEnd() + "…";
-
- _archive.Add(new SessionArchiveEntry
- {
- Key = key,
- Title = session.Title,
- ProjectRoot = session.ProjectRoot.ProjectRoot,
- Model = session.Controller.ModelName,
- ClosedAt = DateTimeOffset.Now,
- TurnCount = turns.Count,
- Preview = preview,
- });
- }
-
- private void OnArchiveChanged()
- {
- if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
- else RefreshHistoryBadge();
- }
-
- /// Marks every current archived conversation as seen, clearing the History rail badge.
- private void MarkHistorySeen()
- {
- _historySeenAt = DateTimeOffset.Now;
- SavePanelState();
- RefreshHistoryBadge();
- }
-
- private void NavHistory_Click(object sender, RoutedEventArgs e)
- {
- if (_historyPanelOpen) CloseLeftPanel();
- else OpenHistory();
- }
-
- private void CloseHistory_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
-
- private void OpenHistory()
- {
- MarkHistorySeen(); // opening the panel IS reading it — clear the unread badge
- PopulateHistory();
- ShowLeftPanel(HistoryPanel, snapshots: false);
- }
-
- /// Current text in the history search box; empty means "show everything".
- private string _historyFilter = "";
-
- /// Project labels whose History group is folded shut (survives search/reopen/delete).
- private readonly HashSet _collapsedHistoryGroups = new();
-
- private void HistoryGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
- {
- if (sender.Tag is not HistoryGroup g) return;
- g.IsExpanded = true;
- _collapsedHistoryGroups.Remove(g.Project);
- SavePanelState();
- }
-
- private void HistoryGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
- {
- if (sender.Tag is not HistoryGroup g) return;
- g.IsExpanded = false;
- _collapsedHistoryGroups.Add(g.Project);
- SavePanelState();
- }
-
- private void HistorySearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
- {
- if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
- _historyFilter = sender.Text?.Trim() ?? "";
- PopulateHistory();
- }
-
- private static bool Matches(SessionArchiveEntry s, string q) =>
- s.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
- || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
- || (s.Model?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
- || (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
-
- private void PopulateHistory()
- {
- var all = _archive.Items; // newest-first copy
- var storeEmpty = all.Count == 0;
- HistorySearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
-
- var q = _historyFilter;
- var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
-
- // Group by project (freshest project first), newest-first within each, carrying remembered
- // collapse state — same shape as the Snapshots panel.
- var groups = filtered
- .GroupBy(s => s.ProjectLabel)
- .OrderByDescending(g => g.Max(s => s.ClosedAt))
- .Select(g => new HistoryGroup(g.Key, g) { IsExpanded = !_collapsedHistoryGroups.Contains(g.Key) })
- .ToList();
-
- HistoryList.ItemsSource = groups;
-
- var nothingToShow = groups.Count == 0;
- HistoryEmpty.Text = storeEmpty
- ? "No past conversations yet. Close a tab and it lands here — reopen it any time to pick up where you left off. (Clearing a tab with /clear forgets it for good; closing keeps it.)"
- : $"No conversations match “{q}”.";
- HistoryEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
- HistoryScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
- RefreshHistoryBadge();
- }
-
- private void RefreshHistoryBadge()
- {
- // Unread = conversations closed after the last visit. Never-visited (null) counts them all.
- var n = _historySeenAt is { } seen
- ? _archive.Items.Count(s => s.ClosedAt > seen)
- : _archive.Count;
- NavHistoryBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
- NavHistoryBadgeText.Text = n > 99 ? "99+" : n.ToString();
- }
-
- /// Reopens an archived conversation as a fresh tab on its original persist-key, so the
- /// standard restore cascade (transcript replay → memory rehydrate) brings it back. The row
- /// leaves the archive — it's live again — but its files stay; closing re-archives it.
- private void HistoryOpen_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
-
- // Defensive: an archived key should never also be open, but if it is, just go there.
- var existing = _tabs.FirstOrDefault(t =>
- string.Equals(t.View.Session.PersistKey, entry.Key, StringComparison.OrdinalIgnoreCase));
- if (existing != null)
- {
- _archive.Remove(entry.Key, deleteFiles: false);
- CloseLeftPanel();
- SwitchPage("chat");
- SelectTab(existing);
- return;
- }
-
- // Fall back to the current directory if the original folder is gone — the transcript and
- // memory still restore; only new file operations would need a live folder.
- var root = Directory.Exists(entry.ProjectRoot) ? entry.ProjectRoot : Environment.CurrentDirectory;
- var tab = CreateChatTab(root, entry.Title, entry.Model, entry.Key); // CreateChatTab selects it
- _archive.Remove(entry.Key, deleteFiles: false);
- CloseLeftPanel();
- SwitchPage("chat");
- _ = InitTabAsync(tab); // InitializeAsync replays the transcript; then model + memory restore
- SaveWorkspace();
- }
-
- private void HistoryDelete_Click(object sender, RoutedEventArgs e)
- {
- if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
- _archive.Remove(entry.Key, deleteFiles: true); // explicit forget — files go too
- PopulateHistory();
- }
-
- /// "Make Default for New Agents" — snapshot the selected agent's settings to disk.
- private void MakeDefault_Click(object sender, RoutedEventArgs e)
- {
- var agent = _sessions.Active;
- if (agent == null) return;
-
- _controller.SaveAsDefaults();
- SettingsStatus.Text = $"Saved {agent.Title}'s settings as the default for new agents. "
- + "Agents already open keep their own.";
- }
-
- /// Resets the visible tab's settings to the app's factory defaults (this agent, this
- /// session). Reads a fresh for the defaults and applies each key
- /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the
- /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset.
- private async void ResetTab_Click(object sender, RoutedEventArgs e)
- {
- var d = new MandoCodeConfig(); // factory defaults (property initializers)
- var s = SettingsTabs.SelectedItem;
- var resets = new List<(string Key, string Value)>();
- string tabName;
-
- static string Bool(bool b) => b ? "true" : "false";
- static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture);
-
- if (s == Tab_Behavior)
- {
- tabName = "Behavior";
- resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning)));
- resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals)));
- resets.Add(("autoContinue", Bool(d.EnableAutoContinuation)));
- resets.Add(("maxContinuations", Num(d.MaxAutoContinuations)));
- resets.Add(("timeout", Num(d.RequestTimeoutMinutes)));
- resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds)));
- resets.Add(("toolBudget", Num(d.ToolResultCharBudget)));
- resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds)));
- }
- else if (s == Tab_Integrations)
- {
- tabName = "Integrations";
- resets.Add(("webSearch", Bool(d.EnableWebSearch)));
- }
- else
- {
- tabName = "Model";
- resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)));
- resets.Add(("maxTokens", Num(d.MaxTokens)));
- resets.Add(("contextLength", Num(d.ContextLength)));
- resets.Add(("streaming", d.ResponseStreaming));
- }
-
- ResetTabButton.IsEnabled = false;
- foreach (var (key, value) in resets)
- await _controller.ApplyConfigKeyAsync(key, value);
- ResetTabButton.IsEnabled = true;
-
- LoadSettings(); // reflect the restored values (also clears the status line)
- SettingsStatus.Text = $"{tabName} settings reset to factory defaults.";
- }
-
- private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title)
- {
- var label = new TextBlock
- {
- Text = title,
- FontSize = 13,
- VerticalAlignment = VerticalAlignment.Center,
- TextTrimming = TextTrimming.CharacterEllipsis
- };
-
- // Gold dot: an approval is waiting in a tab you aren't looking at.
- var badge = new Ellipse
- {
- Width = 7,
- Height = 7,
- Visibility = Visibility.Collapsed,
- VerticalAlignment = VerticalAlignment.Center,
- Fill = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
- };
-
- // Options "..." menu (rename / snapshot / export / close) replaces a bare close button — so
- // the last remaining tab isn't stuck showing an X it isn't allowed to use.
- var options = new Button
- {
- Padding = new Thickness(3),
- Background = new SolidColorBrush(Colors.Transparent),
- BorderThickness = new Thickness(0),
- VerticalAlignment = VerticalAlignment.Center,
- Content = new FontIcon { Glyph = "", FontSize = 12 } // More
- };
- ToolTipService.SetToolTip(options, "Tab options");
- Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(options, "Tab options");
-
- // A Grid (not a StackPanel) so the label flexes and ellipsizes when the tab is narrow,
- // while the badge and options button stay pinned at the right. LayoutTabStrip sets each
- // header's Width; this just governs how that width is divided.
- var row = new Grid { ColumnSpacing = 7 };
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- Grid.SetColumn(label, 0);
- Grid.SetColumn(badge, 1);
- Grid.SetColumn(options, 2);
- row.Children.Add(label);
- row.Children.Add(badge);
- row.Children.Add(options);
-
- var header = new Border
- {
- Child = row,
- Padding = new Thickness(12, 6, 8, 6),
- CornerRadius = new CornerRadius(7),
- BorderThickness = new Thickness(1),
- BorderBrush = new SolidColorBrush(Colors.Transparent),
- Background = new SolidColorBrush(Colors.Transparent)
- };
- return (header, label, badge);
- }
-
- /// Wired after the entry exists so the menu handlers can close over it.
- private void WireHeader(ChatTabEntry entry)
- {
- // The options Button consumes the pointer, so opening its menu doesn't also raise Tapped
- // on the header. Selecting first would be harmless anyway.
- entry.Header.Tapped += (_, _) => SelectTab(entry);
-
- var row = (Grid)entry.Header.Child;
- var options = (Button)row.Children[^1];
-
- var menu = new MenuFlyout();
-
- var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } };
- rename.Click += (_, _) => _ = RenameTabAsync(entry);
-
- var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } };
- snapshot.Click += (_, _) => entry.View.TakeSnapshotManually();
-
- var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } };
- export.Click += (_, _) => _ = entry.View.ExportTranscriptAsync();
-
- var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
- close.Click += (_, _) => CloseTab(entry);
-
- menu.Items.Add(rename);
- menu.Items.Add(snapshot);
- menu.Items.Add(export);
- menu.Items.Add(new MenuFlyoutSeparator());
- menu.Items.Add(close);
-
- // Closing the last agent is allowed now — it leaves an empty chat (see EnterEmptyState);
- // Settings/MCP simply disable until a new agent exists.
-
- options.Flyout = menu;
- }
-
- /// Renames a tab via a small dialog. The name is display-only (the folder stays in
- /// the header); it survives folder changes and model switches.
- private async Task RenameTabAsync(ChatTabEntry entry)
- {
- var box = new TextBox { Text = entry.View.Session.Title };
- box.SelectAll();
-
- var dialog = new ContentDialog
- {
- Title = "Rename agent",
- Content = box,
- PrimaryButtonText = "Rename",
- CloseButtonText = "Cancel",
- DefaultButton = ContentDialogButton.Primary,
- XamlRoot = Content.XamlRoot,
- };
-
- if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
- var name = box.Text.Trim();
- if (name.Length == 0) return;
-
- entry.View.Session.Title = name;
- entry.Label.Text = name;
- RefreshTabStrip();
- }
-
- /// Selecting an agent also returns you to the chat page — the Settings you were
- /// looking at belonged to the agent you just left.
- private void SelectTab(ChatTabEntry entry)
- {
- // Selecting a tab NEVER changes the compare pair — it only changes the active agent. If that
- // agent is in the pair, ApplyPaneLayout shows the split; otherwise it shows the agent single.
- _selected = entry;
- _sessions.Activate(entry.View.Session);
- RefreshTabStrip();
- SwitchPage("chat");
-
- // Reveal the selected tab. Try now (covers clicking an already-laid-out tab) and again when
- // the strip re-lays-out (covers a just-added agent, whose width/extent settle a frame later,
- // via TabStrip_SizeChanged). Pending stays set until the tab is actually laid out.
- _scrollToSelectedPending = true;
- DispatcherQueue.TryEnqueue(TryScrollToSelected);
- }
-
- private bool _scrollToSelectedPending;
-
- // Scroll the strip so the selected tab is fully visible — a manual ChangeView so a newly created
- // (last) tab scrolls ALL THE WAY to the end. StartBringIntoView only did a minimal scroll and ran
- // before the extent settled, so it stopped short. No-op once the tab is visible; stays pending
- // (retried on the next strip SizeChanged) while the tab isn't laid out yet (ActualWidth == 0).
- private void TryScrollToSelected()
- {
- if (!_scrollToSelectedPending || _selected is null) return;
- var header = _selected.Header;
- if (header.ActualWidth <= 0) return; // not laid out yet — retry on the next SizeChanged
-
- double left = header.TransformToVisual(TabStrip)
- .TransformPoint(new Windows.Foundation.Point(0, 0)).X;
- double right = left + header.ActualWidth;
- double viewLeft = TabScroller.HorizontalOffset;
- double viewRight = viewLeft + TabScroller.ViewportWidth;
- const double pad = 8;
-
- if (right > viewRight) // off the right (e.g. a just-added last tab)
- TabScroller.ChangeView(right - TabScroller.ViewportWidth + pad, null, null);
- else if (left < viewLeft) // off the left
- TabScroller.ChangeView(Math.Max(0, left - pad), null, null);
-
- _scrollToSelectedPending = false;
- }
-
- private void TabStrip_SizeChanged(object sender, SizeChangedEventArgs e) => TryScrollToSelected();
-
- private void CloseTab(ChatTabEntry entry)
- {
- var index = _tabs.IndexOf(entry);
- if (index < 0) return;
-
- _tabs.RemoveAt(index);
- TabStrip.Children.Remove(entry.Header);
-
- // Shut down BEFORE unparenting. Removing the view from the tree unloads the WebView2 and
- // nulls its CoreWebView2, so Close() and any last transcript write would hit null.
- entry.View.Shutdown();
- TabHost.Children.Remove(entry.View);
- _sessions.CloseSession(entry.View.Session);
- ArchiveClosedSession(entry.View.Session); // closed tab = recoverable from History, not gone
-
- // Closing the last agent is allowed: you're left with the empty chat background until you
- // open another. Settings/MCP disable meanwhile (they act on an agent), handled in SwitchPage.
- if (_tabs.Count == 0)
- {
- _selected = null;
- ValidateSplit(); // nothing left to compare → exits split
- EnterEmptyState();
- SaveWorkspace();
- return;
- }
-
- if (!ReferenceEquals(_selected, entry))
- {
- ValidateSplit(); // repair the right pane if that's what closed
- RefreshTabStrip();
- SaveWorkspace();
- return;
- }
-
- _selected = null;
- SelectTab(_tabs[Math.Min(index, _tabs.Count - 1)]);
- ValidateSplit(); // the new selection might collide with the right pane
- SaveWorkspace();
- }
-
- /// Shows the "no agents open" background — the chat area with nothing in it. Bounces off
- /// any full-screen page back to chat (Settings/MCP have no agent to act on now).
- private void EnterEmptyState()
- {
- RefreshTabStrip(); // empties the toast; disables Settings/MCP via RefreshNavIcons
- SwitchPage("chat"); // reveals the empty-state panel + its background
- if (_snapshotsPanelOpen) PopulateSnapshots(); // no agent now → disable Import + show notice
- }
-
- // ============================================================
- // Split / compare view — two agents side by side. The compare PAIR (_compareA left, _compareB
- // right) is a remembered, explicit choice: set only by the Split button and the compare-bar
- // pickers, NEVER by clicking a tab. The split is shown whenever the active tab (_selected) is one
- // of the pair; clicking any other tab shows that agent normally while the pair waits, and
- // clicking a paired tab brings the split back. Both panes are ordinary tab views moved between
- // grid columns via ApplyPaneLayout — never reparented, so their WebViews survive.
- // ============================================================
-
- private ChatTabEntry? _compareA; // left pane
- private ChatTabEntry? _compareB; // right pane
- private double _splitLeftFraction = 0.5; // divider position, preserved across page visits
- private bool _syncingSplitCombos;
- private bool _draggingPane;
-
- /// A valid, distinct compare pair is configured (both agents still open).
- private bool HasComparePair =>
- _compareA != null && _compareB != null
- && _tabs.Contains(_compareA) && _tabs.Contains(_compareB)
- && !ReferenceEquals(_compareA, _compareB);
-
- /// The split is actually being shown right now: a pair exists, we're on the chat page,
- /// and the active tab is one of the two paired agents (clicking any other agent shows it single).
- private bool SplitActive =>
- HasComparePair && _currentPage == "chat" && _selected != null
- && (ReferenceEquals(_selected, _compareA) || ReferenceEquals(_selected, _compareB));
-
- private void SplitButton_Click(object sender, RoutedEventArgs e)
- {
- if (HasComparePair)
- {
- // Toggle: showing the split → turn compare off; pair configured but viewing another
- // agent → jump back into the split.
- if (SplitActive) ExitSplit();
- else if (_compareA != null) SelectTab(_compareA);
- return;
- }
- if (_tabs.Count < 2 || _selected == null) return; // button is disabled here anyway
-
- _compareA = _selected;
- _compareB = _tabs.FirstOrDefault(t => !ReferenceEquals(t, _selected));
- RefreshSplitCombos();
- SwitchPage("chat"); // _selected is in the pair → ApplyPaneLayout shows the split
- RefreshSplitButton();
- }
-
- private void ExitSplit_Click(object sender, RoutedEventArgs e) => ExitSplit();
-
- private void ExitSplit()
- {
- _compareA = null;
- _compareB = null;
- ApplyPaneLayout();
- RefreshSplitButton();
- }
-
- /// Places the visible agent view(s) into columns and sizes them. Single view: column 0
- /// fills (divider + right column collapse to 0). Split: _compareA in column 0, _compareB in
- /// column 2, divider between. Setting Grid.Column does NOT reparent, so WebViews are untouched.
- private void ApplyPaneLayout()
- {
- var split = SplitActive;
- var showingChat = _currentPage == "chat";
-
- foreach (var tab in _tabs)
- {
- bool inPair = ReferenceEquals(tab, _compareA) || ReferenceEquals(tab, _compareB);
- var visible = showingChat && (split ? inPair : ReferenceEquals(tab, _selected));
- tab.View.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
- Grid.SetColumn(tab.View, split && ReferenceEquals(tab, _compareB) ? 2 : 0);
- }
-
- if (split)
- {
- PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
- PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
- PaneSplitCol.Width = GridLength.Auto;
- PaneSplitter.Visibility = Visibility.Visible;
- SplitBar.Visibility = Visibility.Visible;
- }
- else
- {
- PaneLeftCol.Width = new GridLength(1, GridUnitType.Star);
- PaneSplitCol.Width = new GridLength(0);
- PaneRightCol.Width = new GridLength(0);
- PaneSplitter.Visibility = Visibility.Collapsed;
- SplitBar.Visibility = Visibility.Collapsed;
- }
- }
-
- /// Re-fills the two pane pickers and re-selects the sides. Items are plain STRINGS
- /// (agent titles) selected by INDEX into — deliberately NOT ComboBoxItem
- /// objects: adding containers directly as items and rebuilding them makes WinUI's ComboBox throw
- /// COMException 0x80070490 "Element not found" on the next selection. Each combo gets its own
- /// list instance (a shared ItemsSource across two ComboBoxes is asking for trouble).
- private void RefreshSplitCombos()
- {
- _syncingSplitCombos = true;
- SplitLeftCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
- SplitRightCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
- SplitLeftCombo.SelectedIndex = _compareA == null ? -1 : _tabs.IndexOf(_compareA);
- SplitRightCombo.SelectedIndex = _compareB == null ? -1 : _tabs.IndexOf(_compareB);
- _syncingSplitCombos = false;
- }
-
- // Both pickers defer their ENTIRE reaction to the next dispatcher tick. A ComboBox raises
- // SelectionChanged from inside a layout pass, and the reaction restructures the visual tree
- // (moves a ChatTabView + its WebView between grid columns) and rebuilds the pickers — both
- // illegal mid-layout / mid-event and the source of the App-level crash. Off the event, on a
- // clean tick, they're safe. Picking an agent for one pane that's already the other pane swaps
- // the two. The chosen agent becomes active, so the split stays on screen.
- private void SplitLeftCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (_syncingSplitCombos) return;
- var idx = SplitLeftCombo.SelectedIndex;
- if (idx < 0 || idx >= _tabs.Count) return;
- var entry = _tabs[idx];
- DispatcherQueue.TryEnqueue(() =>
- {
- if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareA)) return;
- if (ReferenceEquals(entry, _compareB)) _compareB = _compareA; // swap sides
- _compareA = entry;
- RefreshSplitCombos();
- SelectTab(entry); // make the left pane active so the split stays shown
- });
- }
-
- private void SplitRightCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (_syncingSplitCombos) return;
- var idx = SplitRightCombo.SelectedIndex;
- if (idx < 0 || idx >= _tabs.Count) return;
- var entry = _tabs[idx];
- DispatcherQueue.TryEnqueue(() =>
- {
- if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareB)) return;
- if (ReferenceEquals(entry, _compareA)) _compareA = _compareB; // swap sides
- _compareB = entry;
- RefreshSplitCombos();
- SelectTab(entry); // make the right pane active so the split stays shown
- });
- }
-
- /// Keeps the compare pair valid after the agent set changes. If either paired agent was
- /// closed the pair is dropped (compare turns off); otherwise the pickers are resynced.
- private void ValidateSplit()
- {
- if (_compareA == null && _compareB == null) return; // no compare configured
- if (!HasComparePair)
- {
- _compareA = null;
- _compareB = null;
- ApplyPaneLayout();
- RefreshSplitButton();
- return;
- }
- RefreshSplitCombos();
- ApplyPaneLayout();
- RefreshSplitButton();
- }
-
- private void RefreshSplitButton()
- {
- SplitButton.IsEnabled = HasComparePair || _tabs.Count >= 2;
- var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
- var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
- // Accent whenever a compare pair is configured — even while viewing a non-paired agent — so
- // it reads as "compare is on; click a paired tab (or me) to see it."
- SplitButtonIcon.Foreground = HasComparePair ? accent : normal;
- }
-
- // ---- divider drag: repartition the two panes' star widths by pointer X over TabHost ----
- private void PaneSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
- {
- _draggingPane = true;
- ((UIElement)sender).CapturePointer(e.Pointer);
- }
-
- private void PaneSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingPane) return;
- var w = TabHost.ActualWidth;
- if (w <= 0) return;
- var x = e.GetCurrentPoint(TabHost).Position.X;
- _splitLeftFraction = Math.Clamp(x / w, 0.2, 0.8); // keep both panes usable
- PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
- PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
- }
-
- private void PaneSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
- {
- if (!_draggingPane) return;
- _draggingPane = false;
- ((UIElement)sender).ReleasePointerCapture(e.Pointer);
- }
-
- /// Paints the custom chat background image behind the empty state, so closing every
- /// agent leaves the same backdrop you'd see behind a transcript — same file and opacity. Hidden
- /// when there's no image set, or when an agent is open (its own WebView paints it then). Loaded
- /// via a StorageFile stream, the reliable path for an arbitrary filesystem image in unpackaged
- /// WinUI; best-effort, so a missing/locked file just falls back to the flat themed colour.
- private async Task RefreshEmptyBackgroundAsync()
- {
- var show = _currentPage == "chat" && _tabs.Count == 0;
- var file = ThemeManager.ChatBackgroundFile;
- if (!show || string.IsNullOrEmpty(file) || !File.Exists(file))
- {
- EmptyBgImage.Visibility = Visibility.Collapsed;
- EmptyBgImage.Source = null;
- return;
- }
- try
- {
- var sf = await Windows.Storage.StorageFile.GetFileFromPathAsync(file);
- using var stream = await sf.OpenReadAsync();
- var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage();
- await bmp.SetSourceAsync(stream);
- EmptyBgImage.Source = bmp;
- EmptyBgImage.Opacity = ThemeManager.ChatBackgroundOpacity;
- EmptyBgImage.Visibility = Visibility.Visible;
- }
- catch
- {
- EmptyBgImage.Visibility = Visibility.Collapsed;
- }
- }
-
- private void RefreshTabStrip()
- {
- var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
- var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"];
- var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
- var background = (SolidColorBrush)Application.Current.Resources["MandoBackgroundBrush"];
- var transparent = new SolidColorBrush(Colors.Transparent);
-
- ChatTabEntry? pending = null;
-
- foreach (var tab in _tabs)
- {
- var isSelected = ReferenceEquals(tab, _selected);
- tab.Header.Background = isSelected ? background : transparent;
- tab.Header.BorderBrush = isSelected ? accent : border;
- tab.Label.Foreground = isSelected ? accent : dim;
-
- tab.View.IsSelected = isSelected;
- var badged = tab.View.IsApprovalOpen && !isSelected;
- tab.Badge.Visibility = badged ? Visibility.Visible : Visibility.Collapsed;
-
- // Toast for any approval you can't currently see: a background tab, OR the selected tab
- // while you're away on Settings/MCP/Appearance (its chat — and the approval — is
- // collapsed there, so without this you'd get no notice at all).
- if (tab.View.IsApprovalOpen && (!isSelected || _currentPage != "chat"))
- pending ??= tab;
- }
-
- // With several agents running, "an approval is waiting" is useless without saying where,
- // so the toast names the agent and selecting it is one click.
- _pendingApprovalTab = pending;
- if (pending != null && !_approvalToastDismissed)
- {
- ApprovalToastText.Text = pending.View.ApprovalHeadline;
- ApprovalToastTarget.Text = $"Click to review in \"{pending.View.Session.Title}\"";
- ApprovalToast.Visibility = Visibility.Visible;
- }
- else
- {
- ApprovalToast.Visibility = Visibility.Collapsed;
- if (pending == null) _approvalToastDismissed = false; // next approval earns a fresh toast
- }
-
- RefreshNavIcons();
- RefreshSplitButton();
- LayoutTabStrip();
- }
-
- // Tabs stay a comfortable width when there's room, and only shrink once enough agents are open
- // that they'd otherwise overflow — down to a floor, past which the strip scrolls instead.
- private const double TabComfortableWidth = 200;
- private const double TabMinWidth = 104;
-
- private void LayoutTabStrip()
- {
- int count = _tabs.Count;
- if (count == 0) return;
-
- // The visible strip is the scroller's viewport; a later SizeChanged fixes up the first
- // pass if it hasn't been measured yet (ActualWidth == 0 during early layout).
- double viewport = TabScroller.ActualWidth; // tabs only — the add button now lives outside
- if (viewport <= 0) return;
-
- double spacing = 4 * Math.Max(0, count - 1); // 4px between adjacent tabs
- double avail = viewport - spacing - 8; // margin so rounding never forces a scrollbar
-
- double per = Math.Max(TabMinWidth, Math.Min(TabComfortableWidth, avail / count));
- foreach (var tab in _tabs)
- tab.Header.Width = per;
- }
-
- private void TabScroller_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTabStrip();
-
- // Mouse wheel scrolls the strip horizontally when there are more tabs than fit — a convenience
- // on top of the visible scrollbar (which sits in a reserved bottom lane so it never overlaps
- // the tabs). Touchpad / touch horizontal scrolling works natively.
- private void TabScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
- {
- if (TabScroller.ScrollableWidth <= 0) return; // everything fits; nothing to scroll
- var delta = e.GetCurrentPoint(TabScroller).Properties.MouseWheelDelta;
- TabScroller.ChangeView(TabScroller.HorizontalOffset - delta, null, null);
- e.Handled = true;
- }
-
- private void ApprovalToast_Tapped(object sender, TappedRoutedEventArgs e)
- {
- if (_pendingApprovalTab != null) SelectTab(_pendingApprovalTab);
- }
-
- private void ApprovalToastDismiss_Click(object sender, RoutedEventArgs e)
- {
- _approvalToastDismissed = true;
- ApprovalToast.Visibility = Visibility.Collapsed;
- }
-
- // ============================================================
- // Settings page
- // ============================================================
-
- private bool _loadingSettings;
-
- /// Populates every control from the live config. Guarded so control-change
- /// events fired during population don't write back.
- private void LoadSettings()
- {
- _loadingSettings = true;
- try
- {
- // The SELECTED agent's config, not the saved defaults. Switch agents and this page
- // shows different values.
- var cfg = _controller.Config;
- SettingsAgentChip.Text = _sessions.Active?.Title ?? "";
- EndpointBox.Text = cfg.OllamaEndpoint;
- _modelComboTarget = cfg.GetEffectiveModelName();
- ApplyModelComboTarget();
- S_ContextLength.Value = cfg.ContextLength;
- S_Temperature.Value = cfg.Temperature;
- S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##");
- S_MaxTokens.Value = cfg.MaxTokens;
- S_Streaming.SelectedItem = cfg.ResponseStreaming;
- S_TaskPlanning.IsOn = cfg.EnableTaskPlanning;
- S_DiffApprovals.IsOn = cfg.EnableDiffApprovals;
- S_AutoContinue.IsOn = cfg.EnableAutoContinuation;
- S_MaxContinuations.Value = cfg.MaxAutoContinuations;
- S_RequestTimeout.Value = cfg.RequestTimeoutMinutes;
- S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds;
- S_ToolBudget.Value = cfg.ToolResultCharBudget;
- S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds;
- S_WebSearch.IsOn = cfg.EnableWebSearch;
- S_TavilyKey.Password = cfg.TavilyApiKey ?? "";
- S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden;
- TavilyViewButton.Content = "View";
- TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey);
- for (int i = 0; i < UiTheme.All.Count; i++)
- if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i;
- SettingsStatus.Text = "";
- }
- finally
- {
- _loadingSettings = false;
- }
- }
-
- private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
- {
- var s = sender.SelectedItem;
- TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed;
- TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed;
- TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed;
-
- // "Reset" acts on the visible tab, so its label names that tab.
- ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior"
- : s == Tab_Integrations ? "Reset Integrations" : "Reset Model";
- // Every remaining tab is per-agent now (Appearance moved to its own rail page), so
- // "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)
- {
- 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 BoxedMessages_Toggled(object sender, RoutedEventArgs e)
- {
- if (!_appearanceReady) return; // see _appearanceReady — a Save() here wipes settings
- ThemeManager.SetBoxedMessages(BoxedMessagesToggle.IsOn);
- ApplyThemeToAllTabs(); // live — existing messages re-skin instantly
- }
-
- 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}%";
- 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;
- // The preview WebView renders the image itself (via the userdata host + theme
- // script), so there is no XAML image to update here anymore.
- }
-
- // 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.
- private const int GWL_EXSTYLE = -20;
- private const int WS_EX_LAYERED = 0x80000;
- private const uint LWA_ALPHA = 0x2;
-
- [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
- private static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
- [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
- private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
- [System.Runtime.InteropServices.DllImport("user32.dll")]
- private static extern bool SetLayeredWindowAttributes(nint hWnd, uint crKey, byte bAlpha, uint dwFlags);
-
- private void ApplyWindowOpacity(double opacity)
- {
- var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
- var exStyle = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
- if (opacity >= 0.995)
- {
- SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle & ~(nint)WS_EX_LAYERED);
- }
- else
- {
- SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle | (nint)WS_EX_LAYERED);
- SetLayeredWindowAttributes(hwnd, 0, (byte)Math.Round(opacity * 255), LWA_ALPHA);
- }
- }
-
- private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- 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}.";
- }
-
- private string _modelComboTarget = "";
-
- /// An editable ComboBox drops programmatic Text while its template isn't
- /// loaded (the Settings page starts collapsed) — so the intended model name is kept
- /// here and re-applied on the combo's Loaded event. Selecting the matching pulled
- /// model when one exists also marks it in the dropdown.
- private void ApplyModelComboTarget()
- {
- if (_modelComboTarget.Length == 0) return;
- if (ModelCombo.ItemsSource is IList models)
- {
- var idx = models.IndexOf(_modelComboTarget);
- if (idx >= 0)
- {
- ModelCombo.SelectedIndex = idx;
- return;
- }
- }
- ModelCombo.Text = _modelComboTarget;
- }
-
- /// One write path for the whole page: ConfigKeySetter via the controller.
- private async Task ApplySettingAsync(string key, string value)
- {
- var (ok, message) = await _controller.ApplyConfigKeyAsync(key, value);
- SettingsStatus.Text = message;
- if (!ok) LoadSettings(); // revert the control to the real value
- }
-
- private async void Setting_Toggled(object sender, RoutedEventArgs e)
- {
- if (_loadingSettings) return;
- var toggle = (ToggleSwitch)sender;
- await ApplySettingAsync((string)toggle.Tag, toggle.IsOn ? "true" : "false");
- }
-
- private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
- {
- if (_loadingSettings) return;
-
- // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and
- // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons
- // keep working. If even the old value is gone, reload the whole form from config.
- if (double.IsNaN(args.NewValue))
- {
- if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue;
- else LoadSettings();
- return;
- }
-
- await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString());
- }
-
- private async void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
- {
- if (_loadingSettings) return;
- S_TemperatureLabel.Text = e.NewValue.ToString("0.##");
- await ApplySettingAsync("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
- }
-
- private async void Streaming_Changed(object sender, SelectionChangedEventArgs e)
- {
- if (_loadingSettings || S_Streaming.SelectedItem is not string mode) return;
- await ApplySettingAsync("streaming", mode);
- }
-
- /// Enables View as soon as there's anything to reveal (saved key or fresh typing).
- private void TavilyKey_Changed(object sender, RoutedEventArgs e) =>
- TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0;
-
- private void TavilyView_Click(object sender, RoutedEventArgs e)
- {
- var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible;
- S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden;
- TavilyViewButton.Content = show ? "Hide" : "View";
- }
-
- private async void TavilySave_Click(object sender, RoutedEventArgs e)
- {
- var key = S_TavilyKey.Password;
- if (string.IsNullOrWhiteSpace(key))
- {
- SettingsStatus.Text = "Enter a key first (or type 'clear' to remove the saved one).";
- return;
- }
- await ApplySettingAsync("tavilyKey", key.Trim());
- LoadSettings();
- }
-
- private async void RefreshModels_Click(object sender, RoutedEventArgs e) =>
- await RefreshModelListAsync();
-
- private async Task RefreshModelListAsync()
- {
- ModelListStatus.Text = "Fetching models…";
- var models = await Task.Run(_controller.ListModelsAsync);
- if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text;
- ModelCombo.ItemsSource = models;
- ApplyModelComboTarget();
- ModelListStatus.Text = models.Count == 0
- ? "No models found — is Ollama running? (ollama serve, then ollama pull )"
- : $"{models.Count} model(s) available.";
- }
-
- private async void SettingsSave_Click(object sender, RoutedEventArgs e)
- {
- var endpoint = EndpointBox.Text;
- var model = ModelCombo.Text;
- SettingsStatus.Text = "Connecting… (details land in the chat transcript)";
- await Task.Run(() => _controller.ApplyConnectionSettingsAsync(endpoint, model));
- SettingsStatus.Text = _controller.IsConnected
- ? $"✓ Connected — {_controller.ModelName}"
- : "Couldn't connect — see the chat transcript for details.";
- LoadSettings();
- }
-
- // ============================================================
- // MCP page
- // ============================================================
-
- private async void McpRefresh_Click(object sender, RoutedEventArgs e) => await RefreshMcpListAsync();
-
- // Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter).
- private List _allMcpRows = new();
- private bool _loadingMcp;
-
- private async Task RefreshMcpListAsync()
- {
- // Servers are shared across agents and enabled/disabled per-server now, so MCP is always on
- // at the agent level. Make sure the active agent actually attaches tools (new agents inherit
- // EnableMcp=true from defaults; this only fires for an agent someone turned off previously).
- if (!_controller.Config.EnableMcp)
- await ApplySettingAsync("mcp", "true");
-
- McpPageStatus.Text = "Checking server status…";
- var rows = await Task.Run(_controller.GetMcpStatusRowsAsync);
-
- var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
- var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
- _allMcpRows = rows.Select(r => new McpRow
- {
- Name = r.Name,
- Transport = r.Transport,
- Status = r.Status,
- StatusBrush = r.Connected ? green : gold,
- Enabled = !r.Disabled,
- }).ToList();
-
- ApplyMcpFilter();
- }
-
- private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
- ApplyMcpFilter();
-
- private string _mcpFilter = "all";
-
- private void McpFilter_Click(object sender, RoutedEventArgs e)
- {
- _mcpFilter = (string)((FrameworkElement)sender).Tag;
- McpFilterAll.IsChecked = _mcpFilter == "all";
- McpFilterEnabled.IsChecked = _mcpFilter == "enabled";
- McpFilterDisabled.IsChecked = _mcpFilter == "disabled";
- McpFilterFailed.IsChecked = _mcpFilter == "failed";
- ApplyMcpFilter();
- }
-
- /// Applies search + active chip, then groups into Enabled/Disabled sections. The
- /// programmatic ItemsSource set realizes rows (firing each toggle), guarded in McpEnabled_Toggled.
- private void ApplyMcpFilter()
- {
- var q = McpSearchBox.Text?.Trim() ?? "";
- IEnumerable filtered = _allMcpRows;
- if (!string.IsNullOrEmpty(q))
- filtered = filtered.Where(r =>
- r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
- r.Transport.Contains(q, StringComparison.OrdinalIgnoreCase));
- filtered = _mcpFilter switch
- {
- "enabled" => filtered.Where(r => r.Enabled),
- "disabled" => filtered.Where(r => !r.Enabled),
- "failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
- _ => filtered,
- };
- var shown = filtered.ToList();
-
- var groups = new List();
- var en = shown.Where(r => r.Enabled).ToList();
- var dis = shown.Where(r => !r.Enabled).ToList();
- if (en.Count > 0) groups.Add(new McpRowGroup($"Enabled ({en.Count})", en));
- if (dis.Count > 0) groups.Add(new McpRowGroup($"Disabled ({dis.Count})", dis));
-
- var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
- _loadingMcp = true;
- McpList.ItemsSource = cvs.View;
- _loadingMcp = false;
-
- McpEditButton.IsEnabled = false;
- McpRemoveButton.IsEnabled = false;
-
- var total = _allMcpRows.Count;
- var enabledTotal = _allMcpRows.Count(r => r.Enabled);
- var active = q.Length > 0 || _mcpFilter != "all";
- if (total == 0)
- McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one.";
- else if (active)
- McpPageStatus.Text = $"{shown.Count} of {total} shown · {enabledTotal} enabled";
- else
- McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled";
- }
-
- /// Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts
- /// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload).
- private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
- {
- // Fires while the list realizes rows and binds IsOn — ignore those (state already matches).
- if (_loadingMcp) return;
- if (sender is not ToggleSwitch sw || sw.DataContext is not McpRow row) return;
- if (sw.IsOn == row.Enabled) return;
-
- // Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
- if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
- server.Disabled = !sw.IsOn;
-
- McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
- await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
- await RefreshMcpListAsync();
- }
-
- /// Runs a slash command through the normal pipeline (transcript echo, wizard
- /// overlays, busy state all included), then refreshes the server list.
- private async Task RunMcpCommandAsync(string command)
- {
- if (_controller.IsProcessing)
- {
- McpPageStatus.Text = "Busy — wait for the current request to finish.";
- return;
- }
- await Task.Run(() => _controller.SubmitAsync(command));
- await RefreshMcpListAsync();
- }
-
- private void McpAdd_Click(object sender, RoutedEventArgs e) => OpenMcpEditor(null);
-
- private void McpEdit_Click(object sender, RoutedEventArgs e)
- {
- if (McpList.SelectedItem is not McpRow row)
- {
- McpPageStatus.Text = "Select a server to edit first.";
- return;
- }
- OpenMcpEditor(row.Name);
- }
-
- private void McpList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- var hasSelection = McpList.SelectedItem is McpRow;
- McpEditButton.IsEnabled = hasSelection;
- McpRemoveButton.IsEnabled = hasSelection;
- }
-
- private void McpList_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
- {
- if (McpList.SelectedItem is McpRow row) OpenMcpEditor(row.Name);
- }
-
- private async void McpRemove_Click(object sender, RoutedEventArgs e)
- {
- if (McpList.SelectedItem is not McpRow row)
- {
- McpPageStatus.Text = "Select a server to remove first.";
- return;
- }
- await RunMcpCommandAsync($"/mcp remove {row.Name}");
- }
-
- private async void McpReload_Click(object sender, RoutedEventArgs e) =>
- await RunMcpCommandAsync("/mcp-reload");
-
- // ============================================================
- // MCP server editor modal (add + edit)
- // ============================================================
-
- private string? _mcpEditOriginalName;
-
- private void OpenMcpEditor(string? serverName)
- {
- _mcpEditOriginalName = serverName;
- M_StatusBar.IsOpen = false;
- M_TestToolsTable.Visibility = Visibility.Collapsed;
- M_TestSpin.Visibility = Visibility.Collapsed;
- McpEditorTestButton.IsEnabled = true;
- McpEditorSaveButton.IsEnabled = true;
-
- if (serverName != null && _controller.Config.McpServers.TryGetValue(serverName, out var cfg))
- {
- McpEditorTitle.Text = $"Edit MCP server — {serverName}";
- McpEditorSaveButton.Content = "Save & Reconnect";
- M_Name.Text = serverName;
- M_Transport.SelectedIndex = cfg.IsHttp ? 1 : 0;
- M_Command.Text = cfg.Command ?? "";
- M_Args.Text = string.Join(" ", cfg.Args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a));
- M_Env.Text = string.Join("\n", cfg.Env.Select(kv => $"{kv.Key}={kv.Value}"));
- M_Url.Text = cfg.Url ?? "";
- M_Headers.Text = string.Join("\n", cfg.Headers.Select(kv => $"{kv.Key}={kv.Value}"));
- M_Disabled.IsOn = cfg.Disabled;
- }
- else
- {
- McpEditorTitle.Text = "Add MCP server";
- McpEditorSaveButton.Content = "Save & Connect";
- M_Name.Text = "";
- M_Transport.SelectedIndex = 0;
- M_Command.Text = "";
- M_Args.Text = "";
- M_Env.Text = "";
- M_Url.Text = "";
- M_Headers.Text = "";
- M_Disabled.IsOn = false;
- }
-
- UpdateMcpTransportPanels();
- McpEditorOverlay.Visibility = Visibility.Visible;
- M_Name.Focus(FocusState.Programmatic);
- }
-
- private void M_Transport_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
- UpdateMcpTransportPanels();
-
- private void UpdateMcpTransportPanels()
- {
- // Guard: fires during InitializeComponent before panels exist.
- if (M_StdioPanel == null || M_HttpPanel == null) return;
- var isHttp = M_Transport.SelectedIndex == 1;
- M_HttpPanel.Visibility = isHttp ? Visibility.Visible : Visibility.Collapsed;
- M_StdioPanel.Visibility = isHttp ? Visibility.Collapsed : Visibility.Visible;
- }
-
- private void McpEditorCancel_Click(object sender, RoutedEventArgs e) =>
- McpEditorOverlay.Visibility = Visibility.Collapsed;
-
- private void ShowMcpEditorError(string message)
- {
- M_TestSpin.IsActive = false;
- M_TestSpin.Visibility = Visibility.Collapsed;
- M_TestToolsTable.Visibility = Visibility.Collapsed;
- M_StatusBar.Severity = InfoBarSeverity.Error;
- M_StatusBar.Title = "Check the form";
- M_StatusBar.Message = message;
- M_StatusBar.IsOpen = true;
- }
-
- /// Parses "KEY=value" lines. Returns null (with an error shown) on a bad line.
- private Dictionary? ParseKeyValueLines(string text, string label)
- {
- var dict = new Dictionary();
- foreach (var rawLine in text.Split('\n'))
- {
- var line = rawLine.Trim();
- if (line.Length == 0) continue;
- var eq = line.IndexOf('=');
- if (eq <= 0)
- {
- ShowMcpEditorError($"{label}: '{line}' isn't KEY=value.");
- return null;
- }
- dict[line[..eq].Trim()] = line[(eq + 1)..].Trim();
- }
- return dict;
- }
-
- /// Shared validate-and-build for Test and Save. Shows the error inline and
- /// returns false when the form isn't valid.
- private bool TryBuildServerFromForm(bool checkNameCollision, out string name, out MandoCode.Models.McpServerConfig server)
- {
- M_StatusBar.IsOpen = false;
- server = new MandoCode.Models.McpServerConfig { Disabled = M_Disabled.IsOn };
-
- // Lowercased — servers are referenced by name in tool prefixes (mcp_).
- name = M_Name.Text.Trim().ToLowerInvariant();
- if (string.IsNullOrWhiteSpace(name)) { ShowMcpEditorError("Name cannot be empty."); return false; }
- if (name.Contains(' ')) { ShowMcpEditorError("Name cannot contain spaces."); return false; }
- if (checkNameCollision && _mcpEditOriginalName == null && _controller.Config.McpServers.ContainsKey(name))
- {
- ShowMcpEditorError($"A server named '{name}' already exists — edit it instead, or pick another name.");
- return false;
- }
-
- if (M_Transport.SelectedIndex == 1) // http
- {
- var url = M_Url.Text.Trim();
- if (!Uri.TryCreate(url, UriKind.Absolute, out _))
- {
- ShowMcpEditorError("URL must be absolute (e.g. https://mcp.example.com/mcp).");
- return false;
- }
- server.Url = url;
- server.Transport = "http";
-
- var headers = ParseKeyValueLines(M_Headers.Text, "Headers");
- if (headers == null) return false;
- server.Headers = headers;
- }
- else // stdio
- {
- var command = M_Command.Text.Trim();
- if (string.IsNullOrWhiteSpace(command)) { ShowMcpEditorError("Command cannot be empty."); return false; }
- server.Command = command;
- server.Args = ChatController.ParseShellLikeArgs(M_Args.Text.Trim());
-
- var env = ParseKeyValueLines(M_Env.Text, "Environment variables");
- if (env == null) return false;
- server.Env = env;
- }
-
- return true;
- }
-
- private async void McpEditorTest_Click(object sender, RoutedEventArgs e)
- {
- // No collision check — testing an existing name is fine, nothing is written.
- if (!TryBuildServerFromForm(checkNameCollision: false, out var name, out var server)) return;
-
- M_StatusBar.Severity = InfoBarSeverity.Informational;
- M_StatusBar.Title = "Testing connection…";
- M_StatusBar.Message = "Connecting with these values — nothing is saved, running servers aren't touched.";
- M_StatusBar.IsOpen = true;
- M_TestToolsTable.Visibility = Visibility.Collapsed;
- M_TestSpin.Visibility = Visibility.Visible;
- M_TestSpin.IsActive = true;
- McpEditorTestButton.IsEnabled = false;
- McpEditorSaveButton.IsEnabled = false;
-
- try
- {
- var result = await Task.Run(() => _controller.TestMcpServerAsync(name, server));
-
- M_TestSpin.IsActive = false;
- M_TestSpin.Visibility = Visibility.Collapsed;
-
- if (result.Ok)
- {
- M_StatusBar.Severity = InfoBarSeverity.Success;
- M_StatusBar.Title = $"Connected — {result.Tools.Count} tool(s)";
- M_StatusBar.Message = result.Message;
- if (result.Tools.Count > 0)
- {
- M_TestTools.ItemsSource = result.Tools
- .Select(t => new ToolChip { Name = t.Name, Description = t.Description ?? "(no description)" })
- .ToList();
- M_TestToolsTable.Visibility = Visibility.Visible;
- }
- }
- else
- {
- M_StatusBar.Severity = InfoBarSeverity.Error;
- M_StatusBar.Title = "Connection failed";
- M_StatusBar.Message = result.Message;
- }
- }
- finally
- {
- M_TestSpin.IsActive = false;
- McpEditorTestButton.IsEnabled = true;
- McpEditorSaveButton.IsEnabled = true;
- }
- }
-
- private async void McpEditorSave_Click(object sender, RoutedEventArgs e)
- {
- if (!TryBuildServerFromForm(checkNameCollision: true, out var name, out var server)) return;
-
- McpEditorOverlay.Visibility = Visibility.Collapsed;
- SwitchPage("mcp");
- McpPageStatus.Text = $"Saving '{name}' and connecting…";
-
- var originalName = _mcpEditOriginalName;
- var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server));
- McpPageStatus.Text = message;
- await RefreshMcpListAsync();
- McpPageStatus.Text = message;
- }
-
- // ============================================================
- // Skills page — global (user) skills. All file work lives in SkillCoordinator; this is just
- // the UI + the fan-out call that makes a change land in every open agent's prompt.
- // ============================================================
-
- private string? _editingSkillFolder;
-
- // Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter).
- private List _allSkillRows = new();
-
- private void RefreshSkillsList()
- {
- _allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
- {
- Name = s.Name,
- Description = s.Description,
- Body = s.Body,
- FolderPath = s.FolderPath,
- Enabled = s.Enabled,
- }).ToList();
-
- ApplySkillFilter();
- }
-
- private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
- ApplySkillFilter();
-
- private string _skillFilter = "all";
-
- private void SkillFilter_Click(object sender, RoutedEventArgs e)
- {
- _skillFilter = (string)((FrameworkElement)sender).Tag;
- // Single-select: light the chosen chip, clear the rest.
- SkillFilterAll.IsChecked = _skillFilter == "all";
- SkillFilterEnabled.IsChecked = _skillFilter == "enabled";
- SkillFilterDisabled.IsChecked = _skillFilter == "disabled";
- SkillFilterLarge.IsChecked = _skillFilter == "large";
- ApplySkillFilter();
- }
-
- /// Applies the search text + active chip, then groups the result into Enabled/Disabled
- /// sections. Runs on every refresh and keystroke, so filters survive enable/install/delete.
- private void ApplySkillFilter()
- {
- var q = SkillSearchBox.Text?.Trim() ?? "";
- IEnumerable filtered = _allSkillRows;
- if (!string.IsNullOrEmpty(q))
- filtered = filtered.Where(r =>
- r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
- r.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
- filtered = _skillFilter switch
- {
- "enabled" => filtered.Where(r => r.Enabled),
- "disabled" => filtered.Where(r => !r.Enabled),
- "large" => filtered.Where(r => r.IsLarge),
- _ => filtered,
- };
- var shown = filtered.ToList();
-
- // Group by state — Enabled first, Disabled below; empty sections omitted.
- var groups = new List();
- var en = shown.Where(r => r.Enabled).ToList();
- var dis = shown.Where(r => !r.Enabled).ToList();
- if (en.Count > 0) groups.Add(new SkillRowGroup($"Enabled ({en.Count})", en));
- if (dis.Count > 0) groups.Add(new SkillRowGroup($"Disabled ({dis.Count})", dis));
-
- var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
- SkillsList.ItemsSource = cvs.View;
-
- // Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it.
- SkillEditButton.IsEnabled = false;
- SkillDeleteButton.IsEnabled = false;
-
- var total = _allSkillRows.Count;
- var enabledTotal = _allSkillRows.Count(r => r.Enabled);
- var active = q.Length > 0 || _skillFilter != "all";
- if (total == 0)
- SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one. ({_skillCoordinator.UserSkillsDirectory})";
- else if (active)
- SkillsPageStatus.Text = $"{shown.Count} of {total} shown · {enabledTotal} enabled";
- else
- SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled · {_skillCoordinator.UserSkillsDirectory}";
- }
-
- /// Reload every agent's skill set + prompt, then re-render the list and report.
- private async Task ApplySkillChangeAsync(string status)
- {
- await _skillCoordinator.ReloadAllAsync();
- RefreshSkillsList();
- SkillsPageStatus.Text = status;
- }
-
- private void SkillRefresh_Click(object sender, RoutedEventArgs e) => RefreshSkillsList();
-
- private void SkillsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- var has = SkillsList.SelectedItem is SkillRow;
- SkillEditButton.IsEnabled = has;
- SkillDeleteButton.IsEnabled = has;
- }
-
- private void SkillsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
- {
- if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
- }
-
- private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
- {
- // Toggled also fires while the list realizes rows and binds IsOn from the row. In that case
- // the new state equals the row's stored state — a no-op we must ignore, or realizing the
- // list would rewrite files. A real user flip makes the two differ.
- if (sender is not ToggleSwitch sw || sw.DataContext is not SkillRow row) return;
- if (sw.IsOn == row.Enabled) return;
-
- try
- {
- _skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
- await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
- }
- catch (Exception ex)
- {
- SkillsPageStatus.Text = ex.Message;
- }
- }
-
- private void SkillNew_Click(object sender, RoutedEventArgs e) => OpenSkillEditor(null);
-
- private void SkillEdit_Click(object sender, RoutedEventArgs e)
- {
- if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
- }
-
- private async void SkillDelete_Click(object sender, RoutedEventArgs e)
- {
- if (SkillsList.SelectedItem is not SkillRow row) return;
-
- var dialog = new ContentDialog
- {
- Title = "Delete skill",
- Content = $"Delete “{row.Name}”? This removes its folder from disk and can't be undone.",
- PrimaryButtonText = "Delete",
- CloseButtonText = "Cancel",
- DefaultButton = ContentDialogButton.Close,
- XamlRoot = Content.XamlRoot,
- };
- if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
- try
- {
- _skillCoordinator.DeleteSkill(row.FolderPath);
- await ApplySkillChangeAsync($"Deleted “{row.Name}”.");
- }
- catch (Exception ex)
- {
- SkillsPageStatus.Text = ex.Message;
- }
- }
-
- private void SkillOpenFolder_Click(object sender, RoutedEventArgs e)
- {
- var dir = _skillCoordinator.UserSkillsDirectory;
- try
- {
- System.IO.Directory.CreateDirectory(dir);
- Process.Start(new ProcessStartInfo { FileName = dir, UseShellExecute = true });
- }
- catch (Exception ex)
- {
- SkillsPageStatus.Text = ex.Message;
- }
- }
-
- // ---- Skill editor modal ----
-
- private void OpenSkillEditor(SkillRow? row)
- {
- Sk_StatusBar.IsOpen = false;
- if (row == null)
- {
- _editingSkillFolder = null;
- SkillEditorTitle.Text = "New skill";
- Sk_Name.Text = "";
- Sk_Description.Text = "";
- Sk_Body.Text = "";
- }
- else
- {
- _editingSkillFolder = row.FolderPath;
- SkillEditorTitle.Text = "Edit skill";
- Sk_Name.Text = row.Name;
- Sk_Description.Text = row.Description;
- Sk_Body.Text = row.Body;
- }
-
- // Reset the AI panel and default its model to the active agent's (still changeable).
- Sk_AiIntent.Text = "";
- SetSkillAiBusy(false, "");
- _ = LoadSkillAuthorModelsAsync(_sessions.Active?.Controller.ModelName ?? "");
-
- UpdateSkillBodySize(); // explicit: setting Text="" above won't fire TextChanged if already empty
- SkillEditorOverlay.Visibility = Visibility.Visible;
- Sk_Name.Focus(FocusState.Programmatic);
- }
-
- private void Sk_Body_TextChanged(object sender, TextChangedEventArgs e) => UpdateSkillBodySize();
-
- /// Live size readout for the instructions body — approximate tokens, gold when large,
- /// matching the size column in the skills list.
- private void UpdateSkillBodySize()
- {
- var chars = Sk_Body.Text?.Length ?? 0;
- var tokens = (chars + 3) / 4;
- var large = tokens >= 2000;
- Sk_BodySize.Text = (tokens >= 1000 ? $"≈{tokens / 1000.0:0.0}k tokens" : $"≈{tokens} tokens")
- + (large ? " · large — heavy on local models" : "");
- Sk_BodySize.Foreground = new SolidColorBrush(
- ThemeManager.C(large ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
- }
-
- /// Fills the AI model dropdown: the active agent's model shown selected instantly, then
- /// the full installed-model list streamed in behind it. Mirrors the snapshot picker.
- private async Task LoadSkillAuthorModelsAsync(string activeModel)
- {
- if (string.IsNullOrWhiteSpace(activeModel))
- {
- Sk_AiModel.ItemsSource = null;
- return;
- }
-
- var current = new ModelChoice(activeModel, MandoCodeConfig.IsCloudModel(activeModel));
- Sk_AiModel.ItemsSource = new List { current };
- Sk_AiModel.SelectedIndex = 0;
-
- var result = await _controller.LoadAvailableModelsAsync();
- if (!result.Ok || result.Models.Count == 0) return;
-
- // If the user already picked another model while the list loaded, don't clobber it.
- if ((Sk_AiModel.SelectedItem as ModelChoice)?.Name != activeModel) return;
-
- var choices = result.Models
- .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
- .ToList();
- if (!choices.Any(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase)))
- choices.Insert(0, current);
-
- Sk_AiModel.ItemsSource = choices;
- Sk_AiModel.SelectedItem =
- choices.First(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase));
- }
-
- private void SetSkillAiBusy(bool busy, string status)
- {
- Sk_AiSpin.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
- Sk_AiSpin.IsActive = busy;
- Sk_GenerateButton.IsEnabled = !busy;
- Sk_RefineButton.IsEnabled = !busy;
- Sk_AiStatus.Text = status;
- }
-
- private async void SkillGenerate_Click(object sender, RoutedEventArgs e)
- {
- var intent = Sk_AiIntent.Text.Trim();
- if (intent.Length == 0) { Sk_AiStatus.Text = "Describe what the skill should do first."; return; }
- if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
-
- Sk_StatusBar.IsOpen = false;
- SetSkillAiBusy(true, "Drafting…");
- try
- {
- var endpoint = _sessions.Active!.Config.OllamaEndpoint;
- var draft = await SkillAuthor.GenerateAsync(endpoint, model.Name, intent);
- if (!string.IsNullOrWhiteSpace(draft.Name)) Sk_Name.Text = draft.Name;
- if (!string.IsNullOrWhiteSpace(draft.Description)) Sk_Description.Text = draft.Description;
- if (!string.IsNullOrWhiteSpace(draft.Body)) Sk_Body.Text = draft.Body;
- SetSkillAiBusy(false, "Draft ready — review and edit before saving.");
- }
- catch (Exception ex)
- {
- SetSkillAiBusy(false, "");
- ShowSkillEditorError($"AI draft failed: {ex.Message}");
- }
- }
-
- private async void SkillRefine_Click(object sender, RoutedEventArgs e)
- {
- var instruction = Sk_AiIntent.Text.Trim();
- if (instruction.Length == 0) { Sk_AiStatus.Text = "Type what to change in the box above."; return; }
- if (Sk_Body.Text.Trim().Length == 0) { Sk_AiStatus.Text = "Nothing to refine yet — write or generate instructions first."; return; }
- if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
-
- Sk_StatusBar.IsOpen = false;
- SetSkillAiBusy(true, "Refining…");
- try
- {
- var endpoint = _sessions.Active!.Config.OllamaEndpoint;
- var body = await SkillAuthor.RefineAsync(endpoint, model.Name, Sk_Body.Text, instruction);
- if (!string.IsNullOrWhiteSpace(body)) Sk_Body.Text = body;
- SetSkillAiBusy(false, "Instructions updated.");
- }
- catch (Exception ex)
- {
- SetSkillAiBusy(false, "");
- ShowSkillEditorError($"AI refine failed: {ex.Message}");
- }
- }
-
- private void SkillEditorCancel_Click(object sender, RoutedEventArgs e) =>
- SkillEditorOverlay.Visibility = Visibility.Collapsed;
-
- private void ShowSkillEditorError(string message)
- {
- Sk_StatusBar.Title = "Check the form";
- Sk_StatusBar.Message = message;
- Sk_StatusBar.IsOpen = true;
- }
-
- private async void SkillEditorSave_Click(object sender, RoutedEventArgs e)
- {
- var name = Sk_Name.Text.Trim();
- if (name.Length == 0) { ShowSkillEditorError("Give the skill a name."); return; }
- if (Sk_Body.Text.Trim().Length == 0) { ShowSkillEditorError("The instructions can't be empty."); return; }
-
- try
- {
- _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
- SkillEditorOverlay.Visibility = Visibility.Collapsed;
- await ApplySkillChangeAsync($"Saved “{name}”.");
- }
- catch (Exception ex)
- {
- ShowSkillEditorError(ex.Message);
- }
- }
-
- // ---- Skill install modal ----
-
- private void SkillInstall_Click(object sender, RoutedEventArgs e)
- {
- Sk_InstallMode.SelectedIndex = 0; // Git by default
- Sk_InstallGitPanel.Visibility = Visibility.Visible;
- Sk_InstallLocalPanel.Visibility = Visibility.Collapsed;
- Sk_InstallGitUrl.Text = "";
- Sk_InstallLocalPath.Text = "";
- Sk_InstallStatus.Text = "";
- Sk_InstallError.IsOpen = false;
- Sk_InstallSpin.IsActive = false;
- Sk_InstallSpin.Visibility = Visibility.Collapsed;
- SkillInstallConfirmButton.IsEnabled = true;
- SkillInstallOverlay.Visibility = Visibility.Visible;
- Sk_InstallGitUrl.Focus(FocusState.Programmatic);
- }
-
- private void SkillInstallMode_Changed(object sender, SelectionChangedEventArgs e)
- {
- // Fires during InitializeComponent before the panels exist.
- if (Sk_InstallGitPanel == null || Sk_InstallLocalPanel == null) return;
- var local = Sk_InstallMode.SelectedIndex == 1;
- Sk_InstallGitPanel.Visibility = local ? Visibility.Collapsed : Visibility.Visible;
- Sk_InstallLocalPanel.Visibility = local ? Visibility.Visible : Visibility.Collapsed;
- }
-
- private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
- {
- var picker = new Windows.Storage.Pickers.FolderPicker();
- picker.FileTypeFilter.Add("*");
- WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
- var folder = await picker.PickSingleFolderAsync();
- if (folder != null) Sk_InstallLocalPath.Text = folder.Path;
- }
-
- private async void SkillBrowseZip_Click(object sender, RoutedEventArgs e)
- {
- var picker = new Windows.Storage.Pickers.FileOpenPicker();
- picker.FileTypeFilter.Add(".zip");
- WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
- var file = await picker.PickSingleFileAsync();
- if (file != null) Sk_InstallLocalPath.Text = file.Path;
- }
-
- private void SkillInstallCancel_Click(object sender, RoutedEventArgs e) =>
- SkillInstallOverlay.Visibility = Visibility.Collapsed;
-
- private async void SkillInstallConfirm_Click(object sender, RoutedEventArgs e)
- {
- var source = (Sk_InstallMode.SelectedIndex == 1 ? Sk_InstallLocalPath.Text : Sk_InstallGitUrl.Text).Trim();
- if (source.Length == 0)
- {
- Sk_InstallError.Title = "Nothing to install";
- Sk_InstallError.Message = "Enter a git URL, a .zip path, or a folder path.";
- Sk_InstallError.IsOpen = true;
- return;
- }
-
- Sk_InstallError.IsOpen = false;
- Sk_InstallSpin.Visibility = Visibility.Visible;
- Sk_InstallSpin.IsActive = true;
- Sk_InstallStatus.Text = "Fetching…";
- SkillInstallConfirmButton.IsEnabled = false;
-
- try
- {
- // Clone / extract / copy can block; keep it off the UI thread.
- var result = await Task.Run(() => _skillCoordinator.InstallFrom(source));
-
- // Nothing found: keep the modal open so the user can fix the source, and say what a
- // valid source looks like. (finally still resets the spinner/button below.)
- if (result.Installed.Count == 0 && result.Skipped.Count == 0)
- {
- Sk_InstallError.Title = "No skills found";
- Sk_InstallError.Message = "That source has no SKILL.md. A skill is a folder containing a SKILL.md file — point at one, or at a folder/repo/.zip that holds them (nested is fine).";
- Sk_InstallError.IsOpen = true;
- return;
- }
-
- SkillInstallOverlay.Visibility = Visibility.Collapsed;
- await _skillCoordinator.ReloadAllAsync();
- RefreshSkillsList();
-
- var parts = new List();
- if (result.Installed.Count > 0) parts.Add($"installed {string.Join(", ", result.Installed)}");
- if (result.Skipped.Count > 0) parts.Add($"skipped (already present): {string.Join(", ", result.Skipped)}");
- SkillsPageStatus.Text = string.Join(" · ", parts);
- }
- catch (Exception ex)
- {
- Sk_InstallError.Title = "Install failed";
- Sk_InstallError.Message = ex.Message;
- Sk_InstallError.IsOpen = true;
- }
- finally
- {
- Sk_InstallSpin.IsActive = false;
- Sk_InstallSpin.Visibility = Visibility.Collapsed;
- Sk_InstallStatus.Text = "";
- SkillInstallConfirmButton.IsEnabled = true;
- }
- }
-
}
diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
index 642bad6..5803b20 100644
--- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj
+++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
@@ -68,7 +68,7 @@
+ Text="ChatController must not call _config.Save() — it holds a per-tab config clone, and Save() writes the shared config file. Mutate ConfigCoordinator.Defaults and call SaveDefaults() (or SaveDefaultsFrom(agentConfig)) instead." />
diff --git a/src/MandoCode.Desktop/Services/AgentNaming.cs b/src/MandoCode.Desktop/Services/AgentNaming.cs
new file mode 100644
index 0000000..2318097
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/AgentNaming.cs
@@ -0,0 +1,21 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Default agent labels — "Agent 1", "Agent 2", … — reusing the lowest free number so closing
+/// "Agent 2" then opening a new tab gives "Agent 2" again rather than an ever-climbing count.
+/// Pure so it can be tested without a live (which needs the whole
+/// app object graph). User-renamed titles are simply names that happen to be taken.
+///
+public static class AgentNaming
+{
+ public static string NextFreeName(IEnumerable existingTitles)
+ {
+ var taken = new HashSet(
+ existingTitles.Where(t => !string.IsNullOrEmpty(t))!, StringComparer.Ordinal);
+ for (var n = 1; ; n++)
+ {
+ var candidate = $"Agent {n}";
+ if (!taken.Contains(candidate)) return candidate;
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs
index e8848d4..afa2f55 100644
--- a/src/MandoCode.Desktop/Services/AgentSession.cs
+++ b/src/MandoCode.Desktop/Services/AgentSession.cs
@@ -113,7 +113,7 @@ public AgentSession(
Shell = new ShellRunner(ProjectRoot, Transcript, html);
Controller = new ChatController(
- Ai, Config, Tokens, PlanHandoff, Planner,
+ new AiServiceAdapter(Ai), Config, Tokens, PlanHandoff, Planner,
mcpManager, McpGate, Skills, FileProvider, ProjectRoot,
music, updateCheck, Approvals, Transcript, html, Busy, Shell, PromptGate,
configs, mcp, Snapshots);
diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
new file mode 100644
index 0000000..a8474e8
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
@@ -0,0 +1,62 @@
+using MandoCode.Models;
+using MandoCode.Services;
+using Microsoft.SemanticKernel;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// Forwards straight through to a harness . This is
+/// the ONLY place in the Desktop app that names the concrete AIService's method surface for the
+/// controller's sake, so a harness API change on the pin roll surfaces here — one file to fix —
+/// rather than scattered across ChatController's request loop and approval wiring.
+///
+/// The wrapped instance stays owned by AgentSession; other consumers (memory restore, MCP/skill
+/// refresh) keep using it directly. Only ChatController receives it through this adapter.
+///
+public sealed class AiServiceAdapter : IAiService
+{
+ private readonly AIService _ai;
+
+ public AiServiceAdapter(AIService ai) => _ai = ai;
+
+ public event Action? OnFunctionInvoked
+ {
+ add => _ai.OnFunctionInvoked += value;
+ remove => _ai.OnFunctionInvoked -= value;
+ }
+
+ public event Action? OnFunctionCompleted
+ {
+ add => _ai.OnFunctionCompleted += value;
+ remove => _ai.OnFunctionCompleted -= value;
+ }
+
+ public Func>? OnWriteApprovalRequested
+ {
+ get => _ai.OnWriteApprovalRequested;
+ set => _ai.OnWriteApprovalRequested = value;
+ }
+
+ public Func>? OnDeleteApprovalRequested
+ {
+ get => _ai.OnDeleteApprovalRequested;
+ set => _ai.OnDeleteApprovalRequested = value;
+ }
+
+ public Func>? OnCommandApprovalRequested
+ {
+ get => _ai.OnCommandApprovalRequested;
+ set => _ai.OnCommandApprovalRequested = value;
+ }
+
+ public Task ReinitializeAsync(MandoCodeConfig config) => _ai.ReinitializeAsync(config);
+ public Task RefreshSettingsAsync(MandoCodeConfig config) => _ai.RefreshSettingsAsync(config);
+ public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => _ai.AttachMcpPluginsAsync(cancellationToken);
+ public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => _ai.ValidateModelAsync();
+ public IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default) => _ai.ChatStreamAsync(userMessage, cancellationToken);
+ public string? ExportHistoryJson() => _ai.ExportHistoryJson();
+ public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json);
+ public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync();
+ public Task ClearHistoryAsync() => _ai.ClearHistoryAsync();
+ public Task> GetHistoryAsync() => _ai.GetHistoryAsync();
+}
diff --git a/src/MandoCode.Desktop/Services/ConfigCloning.cs b/src/MandoCode.Desktop/Services/ConfigCloning.cs
new file mode 100644
index 0000000..b7c1127
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ConfigCloning.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using MandoCode.Models;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// Pure cloning — a JSON round-trip followed by a mandatory
+/// . Separated from
+/// (which is coupled to live AgentSessions) so the round-trip can be unit-tested on its own:
+/// it is the exact case-sensitivity trap the config guardrails warn about.
+///
+public static class ConfigCloning
+{
+ // Mirrors the harness's internal ConfigJsonOptions (MandoCodeConfig.cs) — it isn't visible
+ // across the assembly boundary, and the round-trip has to be symmetric with Load()/Save().
+ private static readonly JsonSerializerOptions ReadOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true
+ };
+
+ private static readonly JsonSerializerOptions WriteOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ /// A fresh, fully-detached copy of .
+ public static MandoCodeConfig DeepClone(MandoCodeConfig source)
+ {
+ var json = JsonSerializer.Serialize(source, WriteOptions);
+ var clone = JsonSerializer.Deserialize(json, ReadOptions)
+ ?? throw new InvalidOperationException("Failed to clone MandoCodeConfig.");
+
+ // Mandatory, not cosmetic. System.Text.Json builds McpServers with the default
+ // case-SENSITIVE comparer regardless of the property initializer; ValidateAndClamp
+ // rebuilds it as OrdinalIgnoreCase. Skip this and every MCP server lookup in the clone
+ // silently misses on a casing difference.
+ clone.ValidateAndClamp();
+ return clone;
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
index 63ed2c2..2266b1b 100644
--- a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
+++ b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
@@ -1,5 +1,4 @@
using System.Reflection;
-using System.Text.Json;
using MandoCode.Models;
namespace MandoCode.Desktop.Services;
@@ -29,19 +28,6 @@ namespace MandoCode.Desktop.Services;
///
public sealed class ConfigCoordinator
{
- // Mirrors the harness's internal ConfigJsonOptions (MandoCodeConfig.cs) — it isn't visible
- // across the assembly boundary, and the round-trip has to be symmetric with Load()/Save().
- private static readonly JsonSerializerOptions ReadOptions = new()
- {
- PropertyNameCaseInsensitive = true,
- WriteIndented = true
- };
-
- private static readonly JsonSerializerOptions WriteOptions = new()
- {
- WriteIndented = true
- };
-
// Reflected once. Using reflection rather than a hand-written field list means a config key
// added by the CLI harness is carried by "Make Default" without a change here.
private static readonly PropertyInfo[] SettableProperties = typeof(MandoCodeConfig)
@@ -61,7 +47,7 @@ public sealed class ConfigCoordinator
public ConfigCoordinator(MandoCodeConfig defaults) => Defaults = defaults;
/// A fresh, fully-detached copy of the defaults for a new agent.
- public MandoCodeConfig CreateClone() => DeepClone(Defaults);
+ public MandoCodeConfig CreateClone() => ConfigCloning.DeepClone(Defaults);
///
/// "Make Default for New Agents" — snapshots one agent's settings onto the defaults and
@@ -73,7 +59,7 @@ public void SaveDefaultsFrom(MandoCodeConfig agentConfig)
{
// Deep-clone first: reflection assigns reference-typed members straight across, and
// Defaults must not end up sharing the agent's List/Dictionary instances.
- var snapshot = DeepClone(agentConfig);
+ var snapshot = ConfigCloning.DeepClone(agentConfig);
foreach (var property in SettableProperties)
property.SetValue(Defaults, property.GetValue(snapshot));
@@ -108,22 +94,8 @@ public void SyncMcpServersToAgents()
{
// One fresh clone per agent — a shared dictionary would let one agent's session
// approvals mutate another's.
- session.Config.McpServers = DeepClone(Defaults).McpServers;
+ session.Config.McpServers = ConfigCloning.DeepClone(Defaults).McpServers;
}
}
}
-
- private static MandoCodeConfig DeepClone(MandoCodeConfig source)
- {
- var json = JsonSerializer.Serialize(source, WriteOptions);
- var clone = JsonSerializer.Deserialize(json, ReadOptions)
- ?? throw new InvalidOperationException("Failed to clone MandoCodeConfig.");
-
- // Mandatory, not cosmetic. System.Text.Json builds McpServers with the default
- // case-SENSITIVE comparer regardless of the property initializer; ValidateAndClamp
- // rebuilds it as OrdinalIgnoreCase. Skip this and every MCP server lookup in the clone
- // silently misses on a casing difference.
- clone.ValidateAndClamp();
- return clone;
- }
}
diff --git a/src/MandoCode.Desktop/Services/ContextSnapshot.cs b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
index 5bf2fde..327c6bc 100644
--- a/src/MandoCode.Desktop/Services/ContextSnapshot.cs
+++ b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
@@ -42,19 +42,10 @@ public sealed class ContextSnapshot
public string DisplayTitle => string.IsNullOrWhiteSpace(Name) ? OriginModel : Name!;
[System.Text.Json.Serialization.JsonIgnore]
- public string TimeLabel => CapturedAt.LocalDateTime.ToString("MMM d · h:mm tt");
+ public string TimeLabel => ProjectDisplay.TimeLabel(CapturedAt);
/// Group heading for the panel: the project folder's leaf name, or a stand-in when the
/// snapshot predates project tracking (older files) or was taken outside any folder.
[System.Text.Json.Serialization.JsonIgnore]
- public string ProjectLabel
- {
- get
- {
- if (string.IsNullOrWhiteSpace(ProjectRoot)) return "Unknown project";
- var name = System.IO.Path.GetFileName(
- ProjectRoot.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
- return string.IsNullOrEmpty(name) ? ProjectRoot! : name;
- }
- }
+ public string ProjectLabel => ProjectDisplay.ProjectLabel(ProjectRoot);
}
diff --git a/src/MandoCode.Desktop/Services/CrashLog.cs b/src/MandoCode.Desktop/Services/CrashLog.cs
new file mode 100644
index 0000000..f1db17e
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/CrashLog.cs
@@ -0,0 +1,30 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Best-effort append to the same crash.log the App-level UnhandledException
+/// handler writes to. Use it for a swallowed exception that is worth a diagnostic breadcrumb
+/// but must never surface to the user or take the app down — an unexpected failure in an
+/// otherwise recoverable path.
+///
+/// Not every empty catch belongs here: paths that throw as part of normal operation
+/// (WebView2 host re-mapping, script execution during a teardown race) should stay silent so
+/// this log carries signal, not noise. Logging is itself best-effort — a failure to write is
+/// discarded rather than masking the original error.
+///
+public static class CrashLog
+{
+ private static readonly string LogPath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MandoCode.Desktop", "crash.log");
+
+ public static void Write(string context, Exception ex)
+ {
+ try
+ {
+ System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(LogPath)!);
+ System.IO.File.AppendAllText(LogPath,
+ $"[{DateTimeOffset.Now:O}] {context}: {ex.Message}\n{ex}\n\n");
+ }
+ catch { /* logging is best-effort — never mask the original failure */ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs
new file mode 100644
index 0000000..339d12a
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/IAiService.cs
@@ -0,0 +1,44 @@
+using MandoCode.Models;
+using MandoCode.Services;
+using Microsoft.SemanticKernel;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// The slice of the harness that
+/// actually depends on — its streaming loop, approval wiring, function-call events, and history.
+///
+/// Why this exists: AIService is a concrete type in the pinned, read-only harness
+/// submodule, so we can't put an interface on it directly. Depending on this abstraction instead
+/// (via ) does two things:
+///
+/// - absorbs harness API drift in one place — when the pin rolls forward and a signature
+/// moves, the adapter breaks, not the controller's guts (the approval-wiring seam the README
+/// flags as highest-risk); and
+/// - lets the request loop be driven by a fake in tests, without a live Ollama.
+///
+///
+/// The three approval callbacks are single-assignment Func properties (not events): they are
+/// set to this tab's handlers and nulled when diff approvals are off. That's safe only because each
+/// agent owns its own AIService — see AgentSession.
+///
+public interface IAiService
+{
+ event Action? OnFunctionInvoked;
+ event Action? OnFunctionCompleted;
+
+ Func>? OnWriteApprovalRequested { get; set; }
+ Func>? OnDeleteApprovalRequested { get; set; }
+ Func>? OnCommandApprovalRequested { get; set; }
+
+ Task ReinitializeAsync(MandoCodeConfig config);
+ Task RefreshSettingsAsync(MandoCodeConfig config);
+ Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default);
+ Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync();
+ IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default);
+ string? ExportHistoryJson();
+ int TryRestoreHistoryJson(string json);
+ Task EnterLearnModeAsync();
+ Task ClearHistoryAsync();
+ Task> GetHistoryAsync();
+}
diff --git a/src/MandoCode.Desktop/Services/ITranscriptHtml.cs b/src/MandoCode.Desktop/Services/ITranscriptHtml.cs
new file mode 100644
index 0000000..d4cd4a1
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ITranscriptHtml.cs
@@ -0,0 +1,16 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// The transcript-fragment methods the request loop emits.
+/// implements it; the streaming loop () depends on this
+/// slice rather than the concrete builder, whose BaseDocument reaches into WinUI-only
+/// ThemeManager — so the loop (and its tests) stay free of the Windows App SDK.
+///
+public interface ITranscriptHtml
+{
+ string AssistantCard(string markdown);
+ string Warn(string text);
+ string Error(string text);
+ string Dim(string text);
+ string TokenSummary(string text);
+}
diff --git a/src/MandoCode.Desktop/Services/ProjectDisplay.cs b/src/MandoCode.Desktop/Services/ProjectDisplay.cs
new file mode 100644
index 0000000..1aca9c7
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ProjectDisplay.cs
@@ -0,0 +1,22 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Shared display formatting for the Snapshots and History panels. The project-folder leaf name
+/// and the "MMM d · h:mm tt" timestamp were duplicated verbatim on
+/// and ; one home keeps the two panels visually identical.
+///
+public static class ProjectDisplay
+{
+ /// The project folder's leaf name, or a stand-in when the root is unknown/blank
+ /// (an older file that predates project tracking, or a conversation held outside any folder).
+ public static string ProjectLabel(string? projectRoot)
+ {
+ if (string.IsNullOrWhiteSpace(projectRoot)) return "Unknown project";
+ var name = System.IO.Path.GetFileName(
+ projectRoot.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
+ return string.IsNullOrEmpty(name) ? projectRoot! : name;
+ }
+
+ /// Local "MMM d · h:mm tt" label for a captured/closed timestamp.
+ public static string TimeLabel(DateTimeOffset when) => when.LocalDateTime.ToString("MMM d · h:mm tt");
+}
diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
index aa315e1..ff299f0 100644
--- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
+++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
@@ -31,19 +31,10 @@ public sealed class SessionArchiveEntry
// ---- display helpers for the panel ----
[System.Text.Json.Serialization.JsonIgnore]
- public string TimeLabel => ClosedAt.LocalDateTime.ToString("MMM d · h:mm tt");
+ public string TimeLabel => ProjectDisplay.TimeLabel(ClosedAt);
[System.Text.Json.Serialization.JsonIgnore]
- public string ProjectLabel
- {
- get
- {
- if (string.IsNullOrWhiteSpace(ProjectRoot)) return "Unknown project";
- var name = Path.GetFileName(
- ProjectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
- return string.IsNullOrEmpty(name) ? ProjectRoot : name;
- }
- }
+ public string ProjectLabel => ProjectDisplay.ProjectLabel(ProjectRoot);
/// Card body: the first user message, or an honest stand-in when there wasn't one.
[System.Text.Json.Serialization.JsonIgnore]
diff --git a/src/MandoCode.Desktop/Services/SessionManager.cs b/src/MandoCode.Desktop/Services/SessionManager.cs
index 5f63dca..a85fd97 100644
--- a/src/MandoCode.Desktop/Services/SessionManager.cs
+++ b/src/MandoCode.Desktop/Services/SessionManager.cs
@@ -63,14 +63,7 @@ public AgentSession CreateSession(string? projectRoot = null, string? persistKey
/// label just distinguishes agents; the user can rename it. Reuses the lowest free number so
/// closing "Agent 2" then opening a new one gives "Agent 2" again, not an ever-climbing count.
///
- private string NextAgentName()
- {
- for (var n = 1; ; n++)
- {
- var candidate = $"Agent {n}";
- if (_sessions.All(s => s.Title != candidate)) return candidate;
- }
- }
+ private string NextAgentName() => AgentNaming.NextFreeName(_sessions.Select(s => s.Title));
public void Activate(AgentSession session)
{
diff --git a/src/MandoCode.Desktop/Services/ShellOpen.cs b/src/MandoCode.Desktop/Services/ShellOpen.cs
new file mode 100644
index 0000000..8b57a5b
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ShellOpen.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// Opens a file, folder, or URL with the OS default handler (ShellExecute). Centralizes the
+/// dance that was repeated at every "open this in Explorer / the
+/// browser / its default app" call site. Returns the launch exception (null on success) so each
+/// caller can surface its own message; a dead link or missing handler never crashes the app.
+///
+public static class ShellOpen
+{
+ public static Exception? Try(string target)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = target, UseShellExecute = true });
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return ex;
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
index b3e0e77..907832e 100644
--- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
@@ -10,7 +10,7 @@ namespace MandoCode.Desktop.Services;
/// of the CLI's MarkdownHtmlRenderer + OperationDisplayRenderer + diff panels, using
/// the same underlying models (Markdig markdown, OperationDisplayEvent, DiffLine).
///
-public sealed class TranscriptHtmlBuilder
+public sealed class TranscriptHtmlBuilder : ITranscriptHtml
{
private readonly MandoCodeConfig _config;
@@ -278,9 +278,20 @@ public string HelpCard(IEnumerable<(string Command, string Description)> rows)
return sb.ToString();
}
- /// The transcript host page: styles + the append/clear JS the window calls.
- /// Colors come from the active UiTheme; ThemeManager.BuildTranscriptScript re-points
- /// the same CSS variables when the theme changes at runtime.
+ // The bulk of the transcript host page is static CSS and JS. It lives in
+ // Assets/web/transcript/ (shipped by the Assets\web\** content glob), read once and injected
+ // inline by BaseDocument below — so the page still renders in a single NavigateToString with no
+ // extra fetch and no flash of unstyled content. Only the theme-dependent flags and
+ // :root variables remain in C#.
+ private static readonly Lazy TranscriptCss = new(() => ReadWebAsset("transcript.css"));
+ private static readonly Lazy TranscriptJs = new(() => ReadWebAsset("transcript.js"));
+
+ private static string ReadWebAsset(string fileName) => File.ReadAllText(
+ Path.Combine(AppContext.BaseDirectory, "Assets", "web", "transcript", fileName));
+
+ /// The transcript host page: the theme-dependent header and :root vars inline, with the
+ /// bulk static CSS and JS injected from Assets/web/transcript/. Colors come from the active
+ /// UiTheme; ThemeManager.BuildTranscriptScript re-points the same CSS variables at runtime.
public static string BaseDocument(UiTheme theme) => $$"""
@@ -303,458 +314,7 @@ public static string BaseDocument(UiTheme theme) => $$"""
--chat-bg-image: {{ThemeManager.ChatBackgroundCssValue()}};
--chat-bg-opacity: {{ThemeManager.ChatBackgroundOpacityCss()}};
}
- * { box-sizing: border-box; }
- body {
- background: var(--bg); color: var(--fg);
- 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); }
- to { opacity: 1; transform: none; }
- }
- /* E-ink / flat-motion themes: no fade-in, no hover transitions, no smooth scroll — the
- transcript repaints instantly and stays still, the way an e-reader page does. The
- attribute is set at build time and toggled live by ThemeManager.BuildTranscriptScript. */
- html[data-flat] #log > * { animation: none; }
- html[data-flat] *, html[data-flat] { transition: none !important; scroll-behavior: auto !important; }
- /* E-ink background image: treat the (static) chat-background layer like a Kindle image —
- grayscale + contrast + 1-bit Bayer ordered dithering into black/white halftone dots.
- Applied ONLY to #bg (never the text) and ONLY under the flat/e-ink theme. The layer is
- fixed and repaints once, so even this heavy filter costs nothing per frame. */
- html[data-flat] #bg { filter: url(#eink); }
- /* Color emoji is the loudest break in the paper illusion, so desaturate every emoji-bearing
- surface to grayscale ink: the chrome (react ghost, reaction pills, picker) AND the inline
- emoji in message text (.md) and user echoes. Scoped to the flat/e-ink theme only. Safe and
- static — assistant turns are appended as COMPLETE blocks (ChatController flushes each turn
- via AssistantCard; no token-by-token DOM streaming), so each subtree is filtered once on
- append and never re-rasterized by later appends. Under e-ink every other glyph is already
- ink-gray, so the only visible effect is draining the color out of emoji. */
- html[data-flat] .react-ghost,
- html[data-flat] .rx-pill,
- html[data-flat] #rx-pop .rx,
- html[data-flat] .md,
- html[data-flat] .user-echo { filter: grayscale(1); }
-
- /* ---- CRT picture-tube overlay (aperture-grille tube) ----------------------------------
- Scoped to html[data-crt]. Drawn on two fixed, pointer-events:none pseudo-layers OVER the
- transcript, so the "glass" sits in front of the text. EVERYTHING here is STATIC — no moving
- scanline, no flicker (that is the continuous-repaint trap we keep avoiding); the tube look
- is fixed gradients only, one paint. The set's native chrome outside the WebView is untouched,
- exactly like a real TV where only the picture tube carries scanlines. */
- html[data-crt] body {
- /* phosphor bloom on every glyph — a tight bright core + a wider soft halo reads more
- like real phosphor than one big blur (and keeps text legible). Static, so no per-frame
- cost even though it rides the streaming-text repaint. */
- text-shadow: 0 0 2px rgba(120, 210, 255, 0.55), 0 0 9px rgba(120, 210, 255, 0.42),
- 0 0 18px rgba(120, 210, 255, 0.22);
- }
- html[data-crt] body::before {
- content: ""; position: fixed; inset: 0; z-index: 9998; pointer-events: none;
- background:
- /* horizontal scanlines (4px period: 2px gap + 2px line) */
- repeating-linear-gradient(to bottom,
- rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
- rgba(0,0,0,0.22) 2px, rgba(0,0,0,0.22) 4px),
- /* aperture grille — faint vertical RGB stripes (the aperture-grille tell, not a dot mask) */
- repeating-linear-gradient(to right,
- rgba(255,0,64,0.05) 0, rgba(0,255,128,0.05) 1px,
- rgba(64,128,255,0.05) 2px, rgba(0,0,0,0) 3px);
- }
- html[data-crt] body::after {
- content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
- background:
- /* the two signature aperture-grille damper wires */
- linear-gradient(to bottom,
- transparent calc(33.3% - 1px), rgba(0,0,0,0.30) 33.3%, transparent calc(33.3% + 1px)),
- linear-gradient(to bottom,
- transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + 1px)),
- /* tube-edge vignette */
- radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%);
- }
- /* ---- Boxed messages (Appearance toggle, theme-agnostic) ---------------------------
- Each prompt/response on its own card surface: hard message boundaries and skimmable
- rhythm for long sessions, versus the default flat terminal look. Only theme variables,
- so every palette works. Excluded under W98 — its bevelled message windows are bespoke. */
- /* Frosted glass: cards are slightly translucent with a backdrop blur, so a chat
- background image glows through without ever fighting the text (the blur is what
- preserves contrast over busy wallpapers). Over a plain theme background the effect
- degrades to near-solid — no image, no cost to readability. Blur is static compositing,
- not per-frame work. */
- html[data-cards]:not([data-win98]) .user-echo {
- background: color-mix(in srgb, var(--panel) 82%, transparent);
- backdrop-filter: blur(6px);
- border: 1px solid var(--border); border-radius: 10px;
- padding: 8px 12px; }
- html[data-cards]:not([data-win98]) .assistant {
- background: color-mix(in srgb, var(--panel) 82%, transparent);
- backdrop-filter: blur(6px);
- border: 1px solid var(--border); border-radius: 10px;
- padding: 6px 12px 8px 12px; }
- /* Cards sit on the panel color, so code wells inside switch to the bg color to stay
- visually recessed (they normally use --panel against a --bg page). */
- html[data-cards]:not([data-win98]) .md pre,
- html[data-cards]:not([data-win98]) .md code { background: var(--bg); }
-
- /* ---- Windows 98 chrome -----------------------------------------------------------
- Scoped to html[data-win98]. The 3D language of 1998: silver surfaces, square corners,
- two-tone bevels lit from the top-left (raised = chrome you can press, sunken = wells
- that hold content), navy title-bar gradients, Tahoma, and none of the decoration the
- era didn't have (radii, soft shadows). Colors come from the theme's CSS variables;
- this block only reshapes geometry, bevels, and the title bars. All static — pairs
- with the theme's FlatMotion, because nothing animated in 1998. */
- html[data-win98] body { font-family: Tahoma, "MS Sans Serif", "Segoe UI", sans-serif;
- /* THE desktop teal. Silver never filled a screen in 1998 — it sat in windows on this. */
- background: #008080; padding: 12px 14px 20px 14px; }
- /* Each MESSAGE is its own window on the desktop (not one giant expanding one): user
- prompts are small silver windows; assistant responses are windows whose "MandoCode"
- label becomes the navy title bar — the hover copy/react chips land on it like window
- buttons. Status lines and tool ops sit directly on the teal like desktop icon labels,
- with brightened colors (the theme's dark semantic hues are unreadable on teal).
- (A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */
- html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px;
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
- html[data-win98] .assistant { background: var(--bg);
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
- html[data-win98] .assistant-label {
- background: linear-gradient(90deg, #000080, #1084D0); color: #FFFFFF;
- padding: 3px 10px; margin-bottom: 0; font-weight: 700; }
- html[data-win98] .assistant .md { padding: 2px 12px 8px 12px; }
- html[data-win98] .line { color: #EAF6F4; }
- html[data-win98] .line.info { color: #A8D8FF; }
- html[data-win98] .line.success { color: #90EE90; }
- html[data-win98] .line.warn { color: #FFE082; }
- html[data-win98] .line.error { color: #FF9E8F; }
- html[data-win98] .line.dim, html[data-win98] .op-meta, html[data-win98] .token-summary { color: #B8D8D4; }
- html[data-win98] .op { color: #EAF6F4; }
- html[data-win98] .op-path { color: #EAF6F4; }
- html[data-win98] .op-head a.file-link { color: #AAD4FF; border-bottom-color: #AAD4FF; }
- /* Op-head semantic colors (WebSearch/WebFetch/Write/Delete glyph classes) are theme-dark
- hues built for silver — brighten them on the teal, same mapping as the .line variants. */
- html[data-win98] .op-head.success { color: #90EE90; }
- html[data-win98] .op-head.error, html[data-win98] .op-head.red { color: #FF9E8F; }
- html[data-win98] .op-head.warn { color: #FFE082; }
- html[data-win98] .op-head.info, html[data-win98] .op-head.sky { color: #A8D8FF; }
- html[data-win98] .op-head.dim { color: #B8D8D4; }
- /* Square EVERYTHING. */
- html[data-win98] .panel, html[data-win98] .chip, html[data-win98] .tool-pill,
- html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
- html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
- html[data-win98] .md pre, html[data-win98] .md code, html[data-win98] pre.mono-block,
- html[data-win98] pre.raw, html[data-win98] .op-detail, html[data-win98] #rx-pop,
- html[data-win98] .rx-pill, html[data-win98] #rx-pop .rx { border-radius: 0 !important; }
- /* Raised bevel: anything button-like is a silver 3D control. */
- html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
- html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
- html[data-win98] .tool-pill, html[data-win98] .chip, html[data-win98] .rx-pill {
- background: var(--bg); color: #000;
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
- }
- /* ...and presses in like one. */
- html[data-win98] .copy-chip:active, html[data-win98] .expand-btn:active,
- html[data-win98] .web-toggle:active, html[data-win98] .dv-btn:active,
- html[data-win98] .ue-toggle:active, html[data-win98] .react-ghost:active {
- border-color: #404040 #FFFFFF #FFFFFF #404040;
- }
- /* Panels are little windows: raised silver frame + navy title-bar gradient. */
- html[data-win98] .panel {
- background: var(--bg);
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
- }
- html[data-win98] .panel-header {
- background: linear-gradient(90deg, #000080, #1084D0);
- color: #FFFFFF; border-bottom: none;
- }
- html[data-win98] .panel-header a.file-link { color: #FFFFFF; border-bottom-color: #9CC2E5; }
- /* Content wells are sunken white, like every 98 text box and list view. */
- html[data-win98] .md pre, html[data-win98] pre.cmd, html[data-win98] pre.cmd-out,
- html[data-win98] pre.diff, html[data-win98] pre.mono-block, html[data-win98] pre.raw,
- html[data-win98] .op-detail {
- background: var(--panel);
- border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080;
- }
- html[data-win98] .md code { background: var(--panel); border: 1px solid #808080; }
- html[data-win98] .md pre code { border: none; }
- /* 1998 had no soft shadows. */
- html[data-win98] #rx-pop { box-shadow: none; background: var(--bg);
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
- html[data-win98] .chip .dot, html[data-win98] .tool-pill .tp-dot { box-shadow: none; }
- /* Plan/help tables become 98 list views: sunken white body, RAISED column headers —
- the iconic Explorer detail. Row separators in dialog-face gray. */
- html[data-win98] table.plan { background: var(--panel);
- border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; }
- html[data-win98] table.plan th { background: var(--bg); color: #000;
- border: 1px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
- html[data-win98] table.plan td { border-top: 1px solid #D4D0C8; }
- /* Chunky classic scrollbars. */
- html[data-win98] ::-webkit-scrollbar { width: 16px; height: 16px; }
- html[data-win98] ::-webkit-scrollbar-track { background: #DFDFDF; }
- html[data-win98] ::-webkit-scrollbar-thumb { background: var(--bg);
- border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
- html[data-win98] ::-webkit-scrollbar-corner { background: #DFDFDF; }
-
- /* User prompts: gold marks the user's voice, at normal weight so an 8-line clamped
- paste reads as text, not a block of emphasis. Only the sigil stays semibold. */
- .user-echo { color: var(--gold); white-space: pre-wrap; margin-top: 14px; }
- .ue-sigil { font-weight: 600; }
- /* Long prompts clamp to ~8 lines (JS adds the class only when the echo is actually tall).
- The fade is a mask on the text itself — not an overlay painted in a background color —
- so it works over chat-background images and every theme. */
- .user-echo.clamped { max-height: 11.5em; overflow: hidden;
- -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent);
- mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent); }
- .ue-toggle { display: block; background: none; border: none; cursor: pointer;
- color: var(--dim); font-size: 11px; font-family: "Segoe UI", sans-serif; padding: 1px 0; }
- .ue-toggle:hover { color: var(--fg); }
- .assistant { margin-top: 4px; position: relative; }
- .assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; }
- .md p { margin: 6px 0; }
- .md pre {
- background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
- padding: 10px 12px; overflow-x: auto; position: relative;
- font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
- }
- .md code { font-family: "Cascadia Code", Consolas, monospace; background: var(--panel);
- border-radius: 4px; padding: 1px 5px; font-size: 13px; }
- .md pre code { background: none; padding: 0; }
- .md table { border-collapse: collapse; margin: 8px 0; }
- .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; }
- a { color: var(--sky); }
- .md h1, .md h2, .md h3 { color: var(--accent); margin: 12px 0 4px 0; }
- .md ul, .md ol { margin: 4px 0; padding-left: 24px; }
- .md blockquote { border-left: 3px solid var(--accent); margin: 6px 0; padding-left: 10px; color: var(--dim); }
- .line { white-space: pre-wrap; }
- .info { color: var(--sky); }
- .success { color: var(--green); }
- .warn { color: var(--gold); }
- .error { color: var(--red); }
- .dim { color: var(--dim); }
- .sky { color: var(--sky); }
- .red { color: var(--red); }
- .token-summary { text-align: right; font-size: 12px; }
-
- /* Status chips — compact pills for session/connection state. A CSS status dot
- (crisp, theme-aware) replaces status emoji; state = ok | warn | err | neutral. */
- /* Centered to match the tool pills: all system/status chrome sits centered, conversation stays left. */
- .chip-row { margin: 2px 0; text-align: center; }
- .chip { display: inline-flex; align-items: center; gap: 7px;
- padding: 3px 12px; border-radius: 999px; font-size: 12.5px;
- border: 1px solid var(--border); background: var(--panel);
- /* Uniform floor so status pills line up — the "MCP / N connected" pill is the
- widest of them, so shorter pills (model / ready) pad up to match. Longer
- chips still grow past it. */
- box-sizing: border-box; min-width: 190px; }
- .chip .dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
- background: var(--dim); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
- .chip.ok .dot { background: var(--green);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 24%, transparent); }
- .chip.warn .dot { background: var(--gold);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 24%, transparent); }
- .chip.err .dot { background: var(--red);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 24%, transparent); }
- .chip-val { color: var(--fg); font-weight: 600; }
- .chip-key { color: var(--dim); }
-
- /* Tool-call pills — STATIC (no animation, so they never cause continuous repaint). A rounded,
- theme-colored chip with a monochrome glyph, matching the StatusChip family. */
- /* Centered: tool pills are the assistant's machinery, not dialogue — centering (like Slack/Discord
- system messages) keeps the left column a clean read and marks them as ambient activity.
- display:flex + fit-content makes the chip block-level and shrink-wrapped so margin auto centers it. */
- .tool-pill { display: flex; width: fit-content; align-items: center; gap: 8px; margin: 2px auto;
- padding: 3px 12px; border-radius: 999px; font-size: 12px;
- border: 1px solid var(--border); background: var(--panel); }
- .tool-pill .tp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
- background: var(--dim);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
- .tool-pill .tp-label { color: var(--fg);
- font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
- .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
- overflow: hidden; }
- .panel.red-border { border-color: var(--red); }
- .panel-header { padding: 6px 12px; font-weight: 600; border-bottom: 1px solid var(--border);
- font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
- .panel-footer { padding: 4px 12px 8px 12px; color: var(--dim); font-size: 12px; }
- pre.cmd, pre.cmd-out, pre.diff, pre.mono-block, pre.raw {
- margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre;
- font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
- }
- pre.mono-block, pre.raw { background: var(--panel); border: 1px solid var(--border);
- border-radius: 8px; white-space: pre-wrap; }
- .d-add { color: var(--diffadd); display: block; }
- .d-rem { color: var(--red); display: block; }
- .d-ctx { color: var(--dim); display: block; }
-
- /* Collapsible long panels: a big write/diff/output otherwise fills the screen and forces
- endless scrolling, so panel-hosted blocks taller than ~22% of the window collapse to that
- preview height by default. A matching Expand/Collapse button sits in the top-RIGHT and
- bottom-RIGHT corners (JS adds them only when a block is actually tall) so it's reachable
- whether you're at the top or, after expanding, down at the bottom. The header and footer
- pad on the right to clear the buttons. Pure class flip on click — no animation loop. */
- .collapsible-panel { position: relative; }
- .collapsible-panel > .panel-header,
- .collapsible-panel > .panel-footer {
- padding-right: 84px;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
- }
- /* Reserve a bottom gutter so the bottom corner buttons never overlap the last line of a
- footerless panel (e.g. command output). */
- pre.collapsible { position: relative; padding-bottom: 34px; }
- pre.collapsible.collapsed { max-height: 22vh; overflow-y: hidden; }
- .collapse-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 44px;
- pointer-events: none; background: linear-gradient(to bottom, transparent, var(--panel)); }
- .expand-btn { position: absolute; top: 6px; z-index: 3; cursor: pointer;
- background: var(--bg); color: var(--dim); border: 1px solid var(--border);
- border-radius: 6px; padding: 2px 9px; font-size: 11px;
- font-family: "Segoe UI", sans-serif; opacity: 0.9; }
- .expand-btn.left { left: 6px; }
- .expand-btn.right { right: 6px; }
- .expand-btn.bottom { top: auto; bottom: 6px; }
- .expand-btn:hover { color: var(--fg); border-color: var(--accent); opacity: 1; }
-
- /* Web fetch/search previews: noisy reference text, hidden by default behind an inline Expand
- chip on the op line. Expanding reveals the detail box, which reuses the corner Collapse
- button (.expand-btn.right) so it can be closed from the window itself. */
- .web-toggle { margin-left: 8px; cursor: pointer; vertical-align: baseline;
- background: var(--bg); color: var(--dim); border: 1px solid var(--border);
- border-radius: 6px; padding: 1px 8px; font-size: 11px; font-family: "Segoe UI", sans-serif; }
- .web-toggle:hover { color: var(--fg); border-color: var(--accent); }
- .web-detail { position: relative; margin-top: 4px; }
- .web-detail[hidden] { display: none; }
- .web-detail > .op-detail { margin-top: 0; }
- /* Action chips on USER-requested diff cards (Changes-tab clicks): Undo posts to the host,
- Clear removes the card. Floated right in the header; the collapsible-panel header's
- right padding keeps them clear of the corner Expand button. */
- .dv-actions { float: right; display: inline-flex; gap: 6px; }
- .dv-btn { background: var(--bg); color: var(--dim); border: 1px solid var(--border);
- border-radius: 6px; padding: 1px 8px; font-size: 11px;
- font-family: "Segoe UI", sans-serif; cursor: pointer; }
- .dv-btn:hover { color: var(--fg); border-color: var(--accent); }
- 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); }
- .op { margin: 2px 0; }
- .op-head { font-weight: 600; }
- .op-path { font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
- .op-meta { color: var(--dim); font-size: 12px; }
- .op-detail { margin-top: 4px; background: var(--panel); border: 1px solid var(--border);
- border-radius: 8px; }
- /* Prose tool output (web search/fetch): wrap to width, reading font, dimmed — reference material,
- not a code block. Declared after pre.cmd-out so these win on shared properties. */
- pre.op-prose { white-space: pre-wrap; word-break: break-word; overflow-x: hidden;
- font-family: "Segoe UI", sans-serif; font-size: 12.5px; color: var(--dim); }
- table.plan { border-collapse: collapse; width: 100%; }
- table.plan th, table.plan td { border-top: 1px solid var(--border); padding: 5px 12px;
- text-align: left; vertical-align: top; }
- table.plan th { color: var(--dim); font-weight: 600; }
- .nowrap { white-space: nowrap; }
-
- /* Syntax highlighting: highlight.js token classes mapped onto the theme's CSS
- variables, so code colors follow every theme (and survive live retheming). */
- .hljs { background: transparent; color: var(--fg); }
- .hljs-comment, .hljs-quote { color: var(--dim); font-style: italic; }
- .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag { color: var(--accent); }
- .hljs-string, .hljs-regexp, .hljs-addition { color: var(--green); }
- .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-meta, .hljs-built_in { color: var(--gold); }
- .hljs-title, .hljs-section, .hljs-name, .hljs-title.function_, .hljs-title.class_ { color: var(--sky); }
- .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-type { color: var(--sky); }
- .hljs-deletion { color: var(--red); }
- .hljs-emphasis { font-style: italic; }
- .hljs-strong { font-weight: bold; }
-
- /* Copy chips — appear on hover over code blocks and assistant messages. Label is
- CSS generated content so it never pollutes the copied innerText. */
- .copy-chip { position: absolute; top: 6px; right: 6px; z-index: 1; opacity: 0;
- transition: opacity 0.12s; background: var(--bg); color: var(--dim);
- border: 1px solid var(--border); border-radius: 6px; padding: 2px 9px;
- font-size: 11px; font-family: "Segoe UI", sans-serif; cursor: pointer; }
- .copy-chip::before { content: "Copy"; }
- .copy-chip.copied::before { content: "Copied ✓"; }
- .copy-chip.copied { color: var(--green); border-color: var(--green); }
- .md pre:hover .copy-chip, .assistant:hover > .copy-chip { opacity: 1; }
- .copy-chip:hover { color: var(--fg); border-color: var(--accent); }
-
- /* Reactions, Teams-style. A ghosted add-reaction button fades in on hover next to the
- copy chip; clicking it opens a floating picker card (mirrors the input box's emoji
- flyout). Chosen reactions sit under the message as pills — no space is reserved
- until one exists. Delivery to the model: ChatController.SubmitAsync. */
- .react-ghost { position: absolute; top: 6px; right: 56px; z-index: 1; opacity: 0;
- transition: opacity 0.12s; background: var(--bg); color: var(--dim);
- border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
- font-size: 12px; cursor: pointer;
- font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
- .assistant:hover > .react-ghost { opacity: 0.55; }
- .react-ghost:hover { opacity: 1 !important; border-color: var(--accent); color: var(--fg); }
- /* The copy chip widens to "Copied ✓" for ~1.4s after a click; the ghost sits close
- enough to collide, so it ducks out for the duration of the flash. */
- .copy-chip.copied ~ .react-ghost { opacity: 0 !important; pointer-events: none; }
- #rx-pop { position: absolute; z-index: 50; display: none; width: 316px;
- background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
- padding: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.45); }
- #rx-pop .rx { background: none; border: 1px solid transparent; border-radius: 6px;
- padding: 2px 5px; font-size: 17px; line-height: 22px; cursor: pointer;
- font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
- #rx-pop .rx:hover { background: var(--bg); border-color: var(--border); }
- #rx-pop .rx.on { background: var(--bg); border-color: var(--accent); }
- /* flex-wrap is the safety net: if emoji glyphs render wider than budgeted (font
- version varies by Windows build), the row wraps inside the card instead of
- bleeding past its border. */
- #rx-pop .rx-quick { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; }
- #rx-pop .rx-more-btn { margin-left: auto; background: none; border: none;
- color: var(--dim); font-size: 12px; cursor: pointer; padding: 2px 6px; }
- #rx-pop .rx-more-btn:hover { color: var(--fg); }
- #rx-pop .rx-grid { display: none; flex-wrap: wrap; gap: 2px; margin-top: 6px;
- padding-top: 6px; border-top: 1px solid var(--border); max-height: 156px;
- overflow-y: auto; }
- .rx-tray { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
- .rx-pill { background: var(--panel); border: 1px solid var(--accent); border-radius: 999px;
- padding: 1px 9px; font-size: 13px; line-height: 19px; cursor: pointer;
- font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
- .rx-pill:hover { border-color: var(--dim); opacity: 0.85; }
-
- /* Consecutive operation cards group into a collapsible run; it stays open while
- the run is active and collapses once a non-operation block lands after it. */
- details.op-group { margin: 2px 0; }
- details.op-group summary { color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }
- details.op-group summary:hover { color: var(--fg); }
- details.op-group > .op { margin-left: 16px; }
-
- /* Jump-to-bottom pill — shows when scrolled away from the live end of the chat. */
- #jump-pill { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
- display: none; z-index: 40; background: var(--panel); color: var(--fg);
- border: 1px solid var(--accent); border-radius: 999px; padding: 6px 14px;
- font-size: 12px; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
-
- /* In-chat find bar (Ctrl+F while the transcript has focus). */
- #findbar { position: fixed; top: 10px; right: 16px; z-index: 60; display: none;
- align-items: center; gap: 6px; background: var(--panel);
- border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
- #findbar input { background: var(--bg); color: var(--fg); border: 1px solid var(--border);
- border-radius: 6px; padding: 3px 8px; font-size: 12px; width: 180px; outline: none; }
- #findbar .find-count { color: var(--dim); font-size: 11px; min-width: 44px; text-align: center; }
- #findbar button { background: none; border: none; color: var(--dim); cursor: pointer;
- font-size: 12px; padding: 2px 6px; }
- #findbar button:hover { color: var(--fg); }
- mark.find-hit { background: var(--gold); color: #000; border-radius: 2px; }
- mark.find-hit.find-current { background: var(--accent); color: #fff; }
-
- /* Scrollbars — Chromium's stock chrome ignores the theme; restyle every scroll surface
- (page, code blocks, reaction picker grid) to match it. */
- ::-webkit-scrollbar { width: 10px; height: 10px; }
- ::-webkit-scrollbar-track { background: transparent; }
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px;
- border: 2px solid transparent; background-clip: padding-box; }
- ::-webkit-scrollbar-thumb:hover { background-color: var(--dim); }
- ::-webkit-scrollbar-corner { background: transparent; }
- #rx-pop .rx-grid::-webkit-scrollbar { width: 7px; }
+{{TranscriptCss.Value}}
@@ -787,470 +347,7 @@ the run is active and collapses once a non-operation block lands after it. */
diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs
index 6e8b256..4f149fd 100644
--- a/src/MandoCode.Desktop/ViewModels/ChatController.cs
+++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs
@@ -16,7 +16,8 @@ namespace MandoCode.Desktop.ViewModels;
///
public sealed partial class ChatController
{
- private readonly AIService _ai;
+ private readonly IAiService _ai;
+ private readonly ResponseStreamer _streamer;
private readonly MandoCodeConfig _config;
private readonly TokenTrackingService _tokenTracker;
private readonly PlanHandoff _planHandoff;
@@ -74,7 +75,11 @@ public void RemoveReaction(string cardId, string emoji) =>
/// Set by AgentSession: receives ("u"/"a", text) for every conversational turn,
/// feeding the per-session ConversationLog that re-briefs the model after a restart.
/// Only real dialogue is logged — /commands and !shell lines are not conversation.
- public Action? ConversationLogger { get; set; }
+ public Action? ConversationLogger
+ {
+ get => _streamer.ConversationLogger;
+ set => _streamer.ConversationLogger = value;
+ }
/// Arms a restored previous-session conversation to ride the next send as
/// imported background — the automatic counterpart of importing a snapshot, used by
@@ -137,7 +142,7 @@ private void NoteShellCommand(string cmd, bool failed, string output)
public event Action? McpEditorRequested;
public ChatController(
- AIService ai,
+ IAiService ai,
MandoCodeConfig config,
TokenTrackingService tokenTracker,
PlanHandoff planHandoff,
@@ -181,6 +186,13 @@ public ChatController(
_mcp = mcp;
_snapshots = snapshots;
+ // The streamed-response loop, split out so it's testable with a fake IAiService. The 401
+ // sign-in walkthrough is a UI wizard, so it rides in as a callback rather than a direct call.
+ _streamer = new ResponseStreamer(_ai, _transcript, _html, _busy, _tokenTracker, _config)
+ {
+ On401 = OfferCloudSigninAsync
+ };
+
// ---- Same wiring as App.razor OnInitialized ----
// Every delegate below is SINGLE-ASSIGNMENT, not multicast. That's safe only because
// _ai, _planHandoff, and _mcpGate are this tab's own instances (see AgentSession) —
@@ -402,53 +414,24 @@ public async Task SubmitAsync(string input)
var needsPlanning = _taskPlanner.RequiresPlanning(input);
var processedInput = ProcessFileReferences(input);
- // Imported snapshots (from "Import" in the Snapshots panel) ride along ONCE, as background
- // the model already knows — the user's echoed message stays their own text. Multiple
- // imports accumulate and are all sent together, each kept as a distinct recap.
+ // Fold the invisible ride-alongs (imported recaps, emoji reactions, external workspace
+ // changes) and any planning nudge into the message the model sees. See
+ // RequestPreambleComposer for the exact framing.
+ processedInput = RequestPreambleComposer.Compose(
+ processedInput,
+ _armedContexts,
+ _pendingReactions.Select(r => (r.Emoji, r.Snippet)).ToList(),
+ _pendingWorkspaceNotes,
+ needsPlanning);
+
+ // Ride-alongs are one-shot — clear what we just folded in so it isn't sent twice.
if (_armedContexts.Count > 0)
{
- var noun = _armedContexts.Count == 1 ? "recap" : "recaps";
- processedInput =
- $"[Imported context — {_armedContexts.Count} {noun} from previous conversations. " +
- "Treat as background you already have; do not reply to it directly.]\n" +
- string.Join("\n\n", _armedContexts) +
- "\n\n[Current request:]\n" + processedInput;
_armedContexts.Clear();
_armedSnapshotIds.Clear(); // a new batch can re-import the same snapshots next time
}
-
- // Emoji reactions ride along the same way — factual feedback the model can steer
- // on, clearly framed as metadata so it's never mistaken for typed text.
- if (_pendingReactions.Count > 0)
- {
- var lines = string.Join("\n", _pendingReactions.Select(r =>
- $"- {r.Emoji} on your response beginning: “{r.Snippet}”"));
- processedInput =
- "[The user reacted to earlier responses with emoji — a feedback signal, not text they typed:]\n" +
- lines +
- "\n\n[Current request:]\n" + processedInput;
- _pendingReactions.Clear();
- }
-
- // Workspace changes the model didn't make and can't see. Framed as facts (not
- // instructions) with an explicit staleness warning, so the model re-reads rather
- // than trusting its memory of file contents.
- if (_pendingWorkspaceNotes.Count > 0)
- {
- var notes = string.Join("\n", _pendingWorkspaceNotes.Select(n => "- " + n));
- processedInput =
- "[Workspace changes since your last turn, made outside this conversation. " +
- "Your memory of affected file contents may be stale — re-read before relying on it:]\n" +
- notes +
- "\n\n[Current request:]\n" + processedInput;
- _pendingWorkspaceNotes.Clear();
- }
-
- if (needsPlanning)
- {
- processedInput += "\n\n[system: this request looks multi-step. " +
- "Call propose_plan now with the breakdown before doing any work.]";
- }
+ _pendingReactions.Clear();
+ _pendingWorkspaceNotes.Clear();
await ProcessDirectRequestAsync(processedInput);
}
@@ -472,95 +455,17 @@ public void CancelActiveRequest()
private async Task ProcessDirectRequestAsync(string input)
{
+ // Reset the per-request operation tracking the function-call event handlers read.
_recentReadCount = 0;
_recentReadFiles.Clear();
_lastOperationType = null;
_requestCts = new CancellationTokenSource();
var token = _requestCts.Token;
-
try
{
- var receivedFirstChunk = false;
- var enumerator = _ai.ChatStreamAsync(input, token).GetAsyncEnumerator(token);
-
- try
- {
- _busy.Start("Thinking...");
-
- if (await enumerator.MoveNextAsync())
- receivedFirstChunk = true;
-
- if (!receivedFirstChunk)
- {
- _busy.Stop();
- _transcript.Append(_html.Warn("No response from model. The request may have exceeded the model's context window."));
- _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window via /config set."));
- return;
- }
-
- // Each element is one completed chat turn — auto-continuations and
- // post-approval turns arrive as additional elements. Flush every turn
- // to the transcript as it completes so its text lands next to the
- // approval/diff cards it belongs with, instead of every turn coalescing
- // into a single card after the final one.
- var segments = new List();
- do
- {
- var segment = enumerator.Current.Trim();
- if (segment.Length > 0)
- {
- segments.Add(segment);
- _transcript.Append(_html.AssistantCard(segment));
- ConversationLogger?.Invoke("a", segment);
- }
- } while (await enumerator.MoveNextAsync());
-
- _busy.Stop();
-
- if (segments.Count == 0)
- {
- _transcript.Append(_html.Warn("Model returned an empty response. The context may be too large for this model."));
- _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window."));
- }
- else
- {
- var responseText = string.Join("\n\n", segments);
- _lastAiResponse = responseText;
-
- if (Looks401(responseText))
- {
- // 401 auto-recovery — same intent as the CLI's TryAutoSigninAfter401Async:
- // offer the sign-in walkthrough right here instead of making the user
- // type /setup and re-navigate to it.
- await OfferCloudSigninAsync();
- }
-
- if (_config.EnableTokenTracking)
- {
- var lastOp = _tokenTracker.LastOperation;
- if (lastOp != null && !lastOp.IsEstimate)
- {
- var tps = lastOp.TokensPerSecond.HasValue ? $": {lastOp.TokensPerSecond.Value:0.#} tok/s" : "";
- _transcript.Append(_html.TokenSummary(
- $"[~{TokenTrackingService.FormatTokenCount(lastOp.PromptTokens)} in, " +
- $"{TokenTrackingService.FormatTokenCount(lastOp.CompletionTokens)} out{tps}]"));
- }
- }
- }
- }
- finally
- {
- await enumerator.DisposeAsync();
- }
- }
- catch (OperationCanceledException)
- {
- _transcript.Append(_html.Warn("Request cancelled."));
- }
- catch (Exception ex)
- {
- _transcript.Append(_html.Error($"Error: {ex.Message}"));
+ var response = await _streamer.StreamAsync(input, token);
+ if (!string.IsNullOrEmpty(response)) _lastAiResponse = response;
}
finally
{
@@ -571,10 +476,6 @@ private async Task ProcessDirectRequestAsync(string input)
}
}
- private static bool Looks401(string responseText)
- => !string.IsNullOrEmpty(responseText)
- && responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase);
-
// ============================================================
// @file references (port of ProcessFileReferences)
// ============================================================
@@ -1020,7 +921,7 @@ private void ShowHelp()
("/config", "Adjust settings — guided wizard (/config set inline)"),
("/retry", "Retry Ollama connection"),
("/learn", "Learn about LLMs and local AI models"),
- ("/music", "Play lofi/synthwave coding music"),
+ ("/music", "Play coding music (also /music-lofi, /music-synthwave for a specific genre)"),
("/music-stop", "Stop music playback"),
("/music-pause", "Pause/resume music"),
("/music-next", "Skip to next track"),
@@ -1824,7 +1725,12 @@ public async Task> GetMcpStatusRowsAsync()
var tools = await client.ListToolsAsync();
toolCount = tools.Count.ToString();
}
- catch { }
+ catch (Exception ex)
+ {
+ // Connected but the tool listing failed — degrade to "?" in the UI, but this is
+ // an unexpected server fault worth a breadcrumb (only hit when the MCP page refreshes).
+ Services.CrashLog.Write($"MCP ListTools({name})", ex);
+ }
status = $"connected · {toolCount} tool(s)";
}
else if (_mcpManager.StartupErrors.TryGetValue(name, out var err))
diff --git a/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs
new file mode 100644
index 0000000..2581911
--- /dev/null
+++ b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs
@@ -0,0 +1,67 @@
+namespace MandoCode.Desktop.ViewModels;
+
+///
+/// Assembles the invisible preamble that rides along with a user's message — imported snapshot
+/// recaps, emoji reactions, workspace changes made outside the conversation, and a planning nudge.
+/// Extracted from ChatController.SubmitAsync as a pure function so the exact framing (which
+/// the model sees but the user never does) can be unit-tested. Each block that fires wraps the
+/// running text with its own "[Current request:]" boundary, in the order armed → reactions →
+/// workspace; the planning nudge is appended last. All three collections are framed as background
+/// facts, never as text the user typed.
+///
+public static class RequestPreambleComposer
+{
+ public static string Compose(
+ string request,
+ IReadOnlyList armedContexts,
+ IReadOnlyList<(string Emoji, string Snippet)> reactions,
+ IReadOnlyList workspaceNotes,
+ bool needsPlanning)
+ {
+ var result = request;
+
+ // Imported snapshots ride along ONCE as background the model already knows — the user's
+ // echoed message stays their own text. Multiple imports accumulate, each a distinct recap.
+ if (armedContexts.Count > 0)
+ {
+ var noun = armedContexts.Count == 1 ? "recap" : "recaps";
+ result =
+ $"[Imported context — {armedContexts.Count} {noun} from previous conversations. " +
+ "Treat as background you already have; do not reply to it directly.]\n" +
+ string.Join("\n\n", armedContexts) +
+ "\n\n[Current request:]\n" + result;
+ }
+
+ // Emoji reactions: factual feedback the model can steer on, framed as metadata so it's
+ // never mistaken for typed text.
+ if (reactions.Count > 0)
+ {
+ var lines = string.Join("\n", reactions.Select(r =>
+ $"- {r.Emoji} on your response beginning: “{r.Snippet}”"));
+ result =
+ "[The user reacted to earlier responses with emoji — a feedback signal, not text they typed:]\n" +
+ lines +
+ "\n\n[Current request:]\n" + result;
+ }
+
+ // Workspace changes the model didn't make and can't see. Framed as facts with an explicit
+ // staleness warning, so the model re-reads rather than trusting its memory of file contents.
+ if (workspaceNotes.Count > 0)
+ {
+ var notes = string.Join("\n", workspaceNotes.Select(n => "- " + n));
+ result =
+ "[Workspace changes since your last turn, made outside this conversation. " +
+ "Your memory of affected file contents may be stale — re-read before relying on it:]\n" +
+ notes +
+ "\n\n[Current request:]\n" + result;
+ }
+
+ if (needsPlanning)
+ {
+ result += "\n\n[system: this request looks multi-step. " +
+ "Call propose_plan now with the breakdown before doing any work.]";
+ }
+
+ return result;
+ }
+}
diff --git a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs
new file mode 100644
index 0000000..569264b
--- /dev/null
+++ b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs
@@ -0,0 +1,137 @@
+using MandoCode.Models;
+using MandoCode.Services;
+using MandoCode.Desktop.Services;
+
+namespace MandoCode.Desktop.ViewModels;
+
+///
+/// Drives one streamed model response: pumps , flushes each
+/// completed turn to the transcript as its own card, and handles the empty-response, 401, and
+/// cancellation cases plus the token summary. Lifted out of ChatController so it can be tested
+/// with a fake — it depends only on constructible, WinUI-free collaborators
+/// (the request-lifecycle bits — the CancellationTokenSource, StateChanged, operation-field resets —
+/// stay in ChatController). The 401 sign-in walkthrough is a UI wizard, so it arrives as the
+/// callback rather than being called directly.
+///
+public sealed class ResponseStreamer
+{
+ private readonly IAiService _ai;
+ private readonly TranscriptWriter _transcript;
+ private readonly ITranscriptHtml _html;
+ private readonly BusyStateService _busy;
+ private readonly TokenTrackingService _tokenTracker;
+ private readonly MandoCodeConfig _config;
+
+ public ResponseStreamer(
+ IAiService ai,
+ TranscriptWriter transcript,
+ ITranscriptHtml html,
+ BusyStateService busy,
+ TokenTrackingService tokenTracker,
+ MandoCodeConfig config)
+ {
+ _ai = ai;
+ _transcript = transcript;
+ _html = html;
+ _busy = busy;
+ _tokenTracker = tokenTracker;
+ _config = config;
+ }
+
+ /// Logs conversational turns ("a" for each assistant turn). Set by ChatController so the
+ /// same logger records both user and assistant turns.
+ public Action? ConversationLogger { get; set; }
+
+ /// Invoked when a response looks like a cloud 401 — ChatController wires the sign-in
+ /// walkthrough here. Null in tests.
+ public Func? On401 { get; set; }
+
+ /// Streams a request to completion and writes it to the transcript. Returns the joined
+ /// response text (empty when the model returned nothing, was cancelled, or errored) so the caller
+ /// can remember the last response. Never throws — cancellation and errors surface as transcript
+ /// lines, matching the original in-controller behavior.
+ public async Task StreamAsync(string input, CancellationToken token)
+ {
+ try
+ {
+ var enumerator = _ai.ChatStreamAsync(input, token).GetAsyncEnumerator(token);
+ try
+ {
+ _busy.Start("Thinking...");
+
+ if (!await enumerator.MoveNextAsync())
+ {
+ _busy.Stop();
+ _transcript.Append(_html.Warn("No response from model. The request may have exceeded the model's context window."));
+ _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window via /config set."));
+ return "";
+ }
+
+ // Each element is one completed chat turn — auto-continuations and post-approval turns
+ // arrive as additional elements. Flush every turn as it completes so its text lands
+ // next to the approval/diff cards it belongs with, instead of coalescing into one card.
+ var segments = new List();
+ do
+ {
+ var segment = enumerator.Current.Trim();
+ if (segment.Length > 0)
+ {
+ segments.Add(segment);
+ _transcript.Append(_html.AssistantCard(segment));
+ ConversationLogger?.Invoke("a", segment);
+ }
+ } while (await enumerator.MoveNextAsync());
+
+ _busy.Stop();
+
+ if (segments.Count == 0)
+ {
+ _transcript.Append(_html.Warn("Model returned an empty response. The context may be too large for this model."));
+ _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window."));
+ return "";
+ }
+
+ var responseText = string.Join("\n\n", segments);
+
+ if (Looks401(responseText) && On401 != null)
+ {
+ // 401 auto-recovery — same intent as the CLI's TryAutoSigninAfter401Async:
+ // offer the sign-in walkthrough inline instead of making the user type /setup.
+ await On401();
+ }
+
+ if (_config.EnableTokenTracking)
+ {
+ var lastOp = _tokenTracker.LastOperation;
+ if (lastOp != null && !lastOp.IsEstimate)
+ {
+ var tps = lastOp.TokensPerSecond.HasValue ? $": {lastOp.TokensPerSecond.Value:0.#} tok/s" : "";
+ _transcript.Append(_html.TokenSummary(
+ $"[~{TokenTrackingService.FormatTokenCount(lastOp.PromptTokens)} in, " +
+ $"{TokenTrackingService.FormatTokenCount(lastOp.CompletionTokens)} out{tps}]"));
+ }
+ }
+
+ return responseText;
+ }
+ finally
+ {
+ await enumerator.DisposeAsync();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _transcript.Append(_html.Warn("Request cancelled."));
+ return "";
+ }
+ catch (Exception ex)
+ {
+ _transcript.Append(_html.Error($"Error: {ex.Message}"));
+ return "";
+ }
+ }
+
+ private static bool Looks401(string responseText)
+ => !string.IsNullOrEmpty(responseText)
+ && responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase);
+}