diff --git a/CLAUDE.md b/CLAUDE.md
index f4b6f42..fd488f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,6 +14,10 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj
**Requirements:** .NET 10 SDK, Windows 10/11 (uses ConPTY + WebView2)
+### Visual Studio: Hot Reload disabled
+
+`Properties/launchSettings.json` ships with `"hotReloadEnabled": false`, and the csproj sets `false` in Debug. Both are workarounds for a `System.ExecutionEngineException` that crashes the app on F5 under .NET 10.0.8 + VS 18 — `Microsoft.Extensions.DotNetDeltaApplier.dll` faults during its own startup before any managed code runs. Ctrl+F5 (Start Without Debugging) is unaffected either way. **Remove both when the runtime bug is fixed upstream.**
+
### Command-line flags
| Flag | Effect |
@@ -21,7 +25,7 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj
| `--clean` | Debug isolation mode — see below. |
**`--clean`** (parsed in `App.OnStartup`, exposed as `App.CleanStart`):
-- `MainWindow.OnLoaded` skips the restore loop and clears the in-memory `SessionManager` so any new sessions in the run don't co-mingle with the persisted set.
+- `MainWindow.OnLoaded` skips the restore loop and clears the in-memory `SessionManager` — both sessions AND groups — so any new work in the run starts from a blank slate.
- `MainViewModel.SaveStateAsync` short-circuits — **nothing is written to `state.json`** for the entire run. Window bounds, layout changes, settings tweaks, and any sessions created during the clean run are all discarded on exit.
- The user's prior `state.json` survives the run untouched, so this is the safe way to test from a blank slate.
@@ -36,7 +40,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js)
AlertDetector → SessionViewModel.RaiseAlert()
```
-- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only
+- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only. Implements `IPseudoTerminal` (`Terminal/IPseudoTerminal.cs`) — the minimum surface needed by `RunInstance` (`DataReceived`, `Exited`, `ExitCode`, `Start`). Tests inject a fake via the `internal RunInstance(item, Func)` constructor.
- **TerminalBridge** (`Terminal/TerminalBridge.cs`): Routes bytes between PTY and xterm.js via WebView2 messages. Surfaces accelerator keys (Ctrl-combos, F-keys, Esc) via `_webView.PreviewKeyDown` — the newer WPF WebView2 wrapper forwards accelerators through standard key events rather than a separate `CoreWebView2Controller.AcceleratorKeyPressed`. Bridge re-raises them as `AcceleratorKeyPressed` so `MainWindow.OnBridgeAcceleratorKey` can run global shortcuts even when the terminal has focus.
- **OutputIndexer** (`Terminal/OutputIndexer.cs`): Async channels → SQLite, strips ANSI
- **AlertDetector** (`Services/AlertDetector.cs`): Regex on raw PTY output, fires after 1.5s idle
@@ -53,10 +57,24 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js)
|---|---|
| `SessionManager` | CRUD for ShellSession models |
| `StateService` | JSON persistence → `%AppData%/CodeShellManager/state.json` |
-| `SearchService` | SQLite FTS5 search of all terminal output |
+| `SearchService` | SQLite FTS5 search of all terminal output; also owns the `project_notes` table |
| `ColorService` | FNV-1a hash of folder path → 12-color palette |
| `GitService` | Async `git branch --show-current` + `git status --porcelain` |
| `AlertDetector` | Pattern matching for Claude prompts/approvals |
+| `CommandPresetsService` | Launch presets + in-session shortcuts |
+| `ClaudeSessionService` | Detects `claude` invocations; finds last `--resume` session id under `~/.claude/projects/` |
+| `UpdateService` | GitHub Releases version check; caches result for 24h at `%AppData%/CodeShellManager/update-cache.json` |
+| `ImportExportService` | Read/write a full `AppState` to a JSON file (settings + sessions backup) |
+| `ToastHelper` | Tray balloon notifications |
+| `SessionRunner` | Per-session owner of `RunInstance` dictionary (run commands runtime) |
+| `RunInstance` | One headless PTY-backed run with ANSI-stripped output buffer |
+| `RunCommandTemplatesService` | Detects project type (dotnet/cargo/node/python/make) → seed run-command list |
+| `WindowsTerminalProfileService` | Reads Windows Terminal `settings.json` from all install variants |
+| `BuiltInTerminalSchemes` | Lookup table of WT default color schemes not present in user `settings.json` |
+| `SchemeMapper` | WT scheme JSON → xterm.js theme JSON (renames `purple` → `magenta`, rewrites background as `rgba()` when opacity < 1) |
+| `CursorShapeMapper` | WT `cursorShape` → xterm.js `cursorStyle` (+ optional forced blink) |
+| `PaddingParser` | WT `padding` shorthand (1/2/4 comma ints) → CSS `Npx` shorthand |
+| `CommandLineSplitter` | Helper — quote-aware split of a Windows commandline into `(exe, args)` |
## Project Structure
@@ -155,6 +173,19 @@ When any override is set, `LaunchSessionAsync` calls `bridge.ApplyProfileOverrid
**Once stamped, profile overrides are independent.** A session keeps its appearance even if the user later edits or deletes the source profile in Windows Terminal.
+## Recently Closed Sessions
+
+Closing a session (`Ctrl+W`, sidebar `✕`, or terminal-toolbar close) pushes a snapshot onto a ring buffer (`AppState.RecentlyClosed`, cap `MainViewModel.MaxRecentlyClosed = 10`, newest first). Two ways to reopen:
+
+- **`Ctrl+Shift+T`** — pops the newest entry and re-launches it via `MainWindow.ReopenClosedSessionAsync`. The reopened session gets a **fresh Id** so it's independent of anything that may still reference the old one.
+- **"Recently closed" list at the top of the New Session dialog** — click an entry to reopen it; that entry is removed from the ring.
+
+Sleep/wake doesn't touch the ring (`SleepSession` bypasses `OnSessionCloseRequested`). `--clean` mode clears the ring at startup (full debug isolation) and never persists changes — `SaveStateAsync` is a no-op in clean mode.
+
+The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from `ShellSession` so PTY/runtime fields (`IsDormant`, `Status`, `LastActivityAt`) don't leak into the ring buffer. `RunCommands` are deep-copied with fresh Ids on both snapshot creation and session recreation, so edits to either side never alias the other.
+
+FTS5 scrollback retention is **out of scope** for v1 — restored sessions start with an empty xterm buffer.
+
## Sleep / Wake (Dormant Sessions)
Sessions can be put to sleep instead of closed — the PTY is torn down but the `ShellSession` is kept in `state.json` (`IsDormant = true`) so it can be relaunched from the sidebar later. Useful when you have many long-running projects but only need a few live at once.
@@ -175,7 +206,10 @@ Sessions can be put to sleep instead of closed — the PTY is torn down but the
Each session can have a list of "run commands" — labelled command lines invoked by the toolbar ▶ button, the F5 keybinding, or the sidebar right-click submenu. Runs spawn a **separate headless `PseudoTerminal`** in the session's working folder (or a fresh `ssh` connection for SSH parents); they do **not** type into the parent PTY, so a Claude session is untouched.
-**Data:** `ShellSession.RunCommands: List { Id, Label, CommandLine, IsDefault }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`.
+**Data:** `ShellSession.RunCommands: List { Id, Label, CommandLine, IsDefault, Mode, PostRunUrl }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`.
+
+- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH parents ignore `Mode` — remote runs always go through bash. Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets.
+- **`PostRunUrl`** (`string?`, default `null`) — when set and the run exits with code 0, `Process.Start` opens the URL via `UseShellExecute=true` (default browser). Failures are swallowed; no health-check polling.
**Templates:** `RunCommandTemplatesService.SeedFor(folder)` detects project type (top-level scan, first-match: dotnet → cargo → node → python → make) and returns a seed list with fresh Ids. Templates are *copied* onto new sessions at creation time; subsequent edits don't propagate back. SSH sessions skip detection (empty list).
@@ -193,7 +227,20 @@ Each session can have a list of "run commands" — labelled command lines invoke
**Lifecycle:** All runs are killed on session close, session sleep, and app exit. `SessionViewModel.Dispose()` calls `Runner.Dispose()` which iterates and disposes every instance. `SleepSession` also calls `vm.Runner.StopAll()` defensively before UI teardown.
-## Alert / Waiting State
+## Per-Session Notes
+
+Each session gets a collapsible 📝 notepad panel between the terminal toolbar and the terminal. Toggled by the 📝 button on the terminal toolbar; the panel is a docked 160px-high `TextBox` (`Visibility.Collapsed` by default).
+
+**Storage:** notes are **not** on `ShellSession` and not in `state.json`. They live in the FTS5 SQLite DB owned by `SearchService` in a separate `project_notes` table keyed by `folder_path` (the session's `WorkingFolder`). Two sessions in the same folder share one note; SSH sessions and sessions with no working folder don't get a note (`vm.WorkingFolder` is empty → save is skipped).
+
+- `SearchService.GetNoteAsync(folderPath)` — `SELECT content FROM project_notes WHERE folder_path = ?`
+- `SearchService.SaveNoteAsync(folderPath, content)` — UPSERT on `folder_path`, stamps `updated_at` (ms since epoch)
+
+**UI lifecycle:** content is lazy-loaded on the first time the panel is opened (`notesLoaded` flag in the toolbar build). Each keystroke restarts a 1-second `System.Threading.Timer` debounce; when it fires, `SaveNoteAsync` is called on the dispatcher thread. No explicit save action — closing the panel or the session just leaves the last debounce to flush. There's no save-on-exit hook, so a note edited in the final ~1s before app close can be lost.
+
+**Search integration:** `SearchService.SearchAsync` queries notes alongside terminal output — notes use `LIKE %query%` (short free-text, FTS5 overkill) and are tagged `SearchResultType.Note` so the search panel can label them. The note's row in the search panel is keyed by folder, not session.
+
+**Dormant sessions:** because notes are folder-keyed and live outside `state.json`, a dormant or reopened session in the same folder transparently picks up the existing note on next wake/restore.
`AlertDetector` fires `AlertRaised(AlertEvent)` after 1.5s idle when it detects:
- **ToolApproval**: Claude asking to run a tool (regex on approval phrases)
@@ -206,6 +253,16 @@ Each session can have a list of "run commands" — labelled command lines invoke
`AlertDetector.NotifyUserInteracted()` clears alert state on user input.
+## Session Spinners
+
+Two overlays cover launch and shutdown so the user sees progress instead of a blank pane.
+
+**Launch overlay (per session)** lives in `Assets/terminal.html` and `Assets/terminal-transparent.html` as a CSS-animated rotating SVG arc with a phase label. Visible by default; `TerminalBridge` posts `setBootState` after `NavigationCompleted` (label = `Starting {cmd}…` for local, `Connecting to {host}…` for SSH; accent = session color) and `bootDone` on the first PTY byte (via `OnPtyData → PostBootDoneIfNeeded`, race-safe via `Interlocked.CompareExchange`). An 8-second fallback timer scheduled in `NavCompleted` also calls `PostBootDoneIfNeeded` so silent sessions and slow SSH handshakes don't lock the user out of the pane.
+
+**Shutdown overlay (app-level)** is a `Grid x:Name="ShutdownOverlay"` on `MainWindow.xaml` with a `Storyboard`-rotated `Path`. `OnClosing` shows it then `await Dispatcher.InvokeAsync(() => {}, DispatcherPriority.Background)` to force a render pass before the existing synchronous session-disposal loop blocks the UI thread.
+
+Full design: `docs/superpowers/specs/2026-05-16-session-spinners-design.md`.
+
## Search
- All PTY output is stripped of ANSI and indexed to SQLite FTS5 by `OutputIndexer`
@@ -231,6 +288,8 @@ Persisted in `state.json`. Key settings:
| Key | Action |
|---|---|
| `Ctrl+T` | New session |
+| `Ctrl+Shift+T` | Reopen the most-recently-closed session (browser convention) |
+| `Ctrl+Alt+T` | Duplicate active session (was `Ctrl+Shift+T` pre-bundle) |
| `Ctrl+W` | Close active session |
| `Ctrl+F` | Toggle search |
| `Ctrl+Tab` | Cycle sessions |
@@ -250,6 +309,10 @@ Unit tests cover model logic (`ShellSession`, etc.) and run headless. UI tests r
`ShellSession.BuildSshArgs()` is `internal` — accessible from tests via `[assembly: InternalsVisibleTo("CodeShellManager.Tests")]` in `AssemblyInfo.cs`.
+**`IPseudoTerminal` testability seam.** `PseudoTerminal` implements `IPseudoTerminal` (in `Terminal/IPseudoTerminal.cs`), and `RunInstance` / `SessionRunner` both expose an `internal` constructor that accepts a `Func` factory. Production code uses the parameterless public ctors which default to `() => new PseudoTerminal()`; tests pass a hand-rolled `FakePseudoTerminal` to exercise the run-command lifecycle (Run, Stop, Dismiss, kill-and-restart, 1MB output-buffer cap) without spawning a real ConPTY child. Keep the interface surface minimal — only what `RunInstance` actually calls (`DataReceived`, `Exited`, `ExitCode`, `Start`).
+
+**SearchService tests** open a fresh file-backed SQLite at `Path.GetTempPath()` per test for isolation. The test class is `IDisposable` and clears the connection pool (`SqliteConnection.ClearAllPools()`) before deleting the file on Windows. Seed `session_history` rows with explicit timestamps rather than `Task.Delay` — Windows' 15.6ms timer granularity makes wall-clock-based ordering flaky on CI.
+
## Releases
CI/CD is in `.github/workflows/build.yml`. Releases are triggered by pushing a `v*.*.*` tag:
diff --git a/README.md b/README.md
index ef1eb53..383c071 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,10 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu
## Features
- **Multi-terminal grid** — run up to 18 sessions simultaneously in configurable layouts (1, 2, 3, 4, 6 columns; 2×2, 6×2, 6×3 grids); the active pane is highlighted with a 2px accent ring so it's easy to spot
+- **Sidebar groups** — organise sessions into named groups with their own color and filter strip; bulk actions (sleep / close / re-group) operate on the active group
- **Sleep & wake** — 💤 button parks a session: PTY torn down, but the session (and its notes) stays in the sidebar so you can wake it later from where you left off. Great when you have many long-running projects but only need a few live at once.
+- **Recently closed** — Ctrl+Shift+T reopens the last-closed session (browser convention); the New Session dialog also lists the last 10 closed sessions for one-click revival
+- **Per-session run commands** — define a list of labelled commands per session (Test, Build, Watch…); ▶ runs the default, F5 / Shift+F5 run/stop it, output streams into a side drawer without touching the parent terminal. Optional post-run URL opens in your browser on exit code 0.
- **Full-text search** — all terminal output indexed to SQLite FTS5; instant search across every session, ever
- **Per-project notepad** — collapsible 📝 notes panel on every terminal, auto-saved and searchable
- **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators
@@ -27,6 +30,9 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu
- **Session rename** — double-click any session name or click ✏ to rename inline
- **Auto-resume** — automatically resumes the last Claude Code session when restoring on startup (`--resume `); toggleable in Settings
- **SSH remote sessions** — connect to remote hosts using your existing SSH config; sessions persist across restarts
+- **Windows Terminal profile import** — opt-in import of profiles from Windows Terminal's `settings.json`; pick a profile in the New Session dialog to stamp its font, color scheme, cursor and padding onto the new terminal
+- **Launch & shutdown spinners** — every starting session shows a brief overlay (`Starting …` or `Connecting to …`) until the first PTY byte arrives; closing the window shows a "Shutting down…" overlay during session disposal
+- **WSL sessions** — first-class session type for any installed WSL distro: distro picker (auto-detected via `wsl -l -v`), Linux working folder, optional `-u` user override; git status works via the `\\wsl$\` UNC view
- **Session history** — clicking a search result from a closed session offers to relaunch it
- **Configurable launch commands** — customise the commands available in the New Session dialog
- **Claude badge** — sessions running `claude` commands get a visual indicator
@@ -86,9 +92,13 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj
| Key | Action |
|-----|--------|
| `Ctrl+T` | New session |
+| `Ctrl+Shift+T` | Reopen the most-recently-closed session |
+| `Ctrl+Alt+T` | Duplicate the active session |
| `Ctrl+W` | Close active session |
| `Ctrl+F` | Toggle search |
-| `Ctrl+Tab` | Cycle sessions |
+| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle sessions |
+| `F5` | Run the active session's default run-command |
+| `Shift+F5` | Stop the active session's running run-command |
| `Escape` (in search) | Close search panel |
## Layout Options
diff --git a/docs/superpowers/plans/2026-05-16-session-spinners.md b/docs/superpowers/plans/2026-05-16-session-spinners.md
new file mode 100644
index 0000000..9d68244
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-session-spinners.md
@@ -0,0 +1,494 @@
+# Session Spinners Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a per-session "starting…" spinner that lives in the xterm host until the first PTY byte arrives, and a full-window "Shutting down…" overlay during app exit.
+
+**Architecture:** The launch spinner is a CSS overlay inside `terminal.html` / `terminal-transparent.html`, visible by default and hidden via a WebView2 message (`bootDone`) on first PTY output. The shutdown overlay is a WPF `Grid` on `MainWindow`, made visible at the top of `OnClosing` followed by a single `Dispatcher.InvokeAsync(... Background)` yield so the overlay paints before disposal blocks the UI thread.
+
+**Tech Stack:** WPF (.NET 10) + WebView2 + xterm.js + plain CSS/JS. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-05-16-session-spinners-design.md`
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `src/CodeShellManager/Assets/terminal.html` | Boot overlay markup + CSS (opaque variant) |
+| `src/CodeShellManager/Assets/terminal-transparent.html` | Boot overlay markup + CSS (transparent variant — only difference: backdrop is `#1e1e2e` here too because the spinner is opaque even when xterm is transparent) |
+| `src/CodeShellManager/Assets/terminal-init.js` | Add `setBootState` and `bootDone` handlers to the existing `message` event listener |
+| `src/CodeShellManager/Terminal/TerminalBridge.cs` | New `SetBootContext(label, accentHex)` API; post `setBootState` after `NavigationCompleted`; post `bootDone` on first byte from `OnPtyData`; set `CoreWebView2Controller.DefaultBackgroundColor` |
+| `src/CodeShellManager/MainWindow.xaml.cs` | In `LaunchSessionAsync`: call `SetBootContext` before `InitializeAsync`, move `terminalWrapper.Visibility = Visible` earlier. In `OnClosing`: show overlay + yield |
+| `src/CodeShellManager/MainWindow.xaml` | Add `ShutdownOverlay` grid as last child of the root grid |
+
+No new test files — the work is XAML / HTML / JS / WebView2 integration and is verified manually.
+
+---
+
+### Task 1: Add boot overlay markup + CSS to both terminal HTML files
+
+**Files:**
+- Modify: `src/CodeShellManager/Assets/terminal.html`
+- Modify: `src/CodeShellManager/Assets/terminal-transparent.html`
+
+- [ ] **Step 1: Add boot overlay CSS + markup to `terminal.html`**
+
+In `src/CodeShellManager/Assets/terminal.html`, **inside the `` tag (after the `body.retro::before` rule):
+
+```css
+ /* Boot overlay — visible until terminal-init.js receives bootDone */
+ #bootOverlay {
+ position: fixed; inset: 0;
+ background: #1e1e2e;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 14px;
+ font-family: 'Segoe UI', sans-serif;
+ color: #cdd6f4;
+ transition: opacity 200ms ease-out;
+ }
+ #bootOverlay.hidden { opacity: 0; pointer-events: none; }
+ #bootSpinner {
+ width: 44px; height: 44px;
+ --boot-accent: #89b4fa;
+ }
+ #bootSpinner circle {
+ fill: none;
+ stroke: var(--boot-accent);
+ stroke-width: 4;
+ stroke-linecap: round;
+ stroke-dasharray: 90 150;
+ transform-origin: center;
+ animation: bootSpin 1.2s linear infinite;
+ }
+ @keyframes bootSpin { to { transform: rotate(360deg); } }
+ #bootLabel { font-size: 13px; opacity: 0.85; }
+```
+
+Then **in the ``**, immediately after `` and before `
…`, insert:
+
+```html
+
+
+
Initializing terminal…
+
+```
+
+- [ ] **Step 2: Add the same overlay to `terminal-transparent.html`**
+
+Make the identical CSS + markup edit in `src/CodeShellManager/Assets/terminal-transparent.html`. Same rules, same position. The overlay uses an opaque `#1e1e2e` background even on the transparent variant — that's intentional so the spinner is readable.
+
+- [ ] **Step 3: Visually verify by opening one of the HTML files in a browser**
+
+Open `src/CodeShellManager/Assets/terminal.html` directly in Chrome / Edge (drag onto the address bar). The xterm content area won't render (no JS bundling needed for this check) but the boot overlay should be visible: rotating arc + "Initializing terminal…" label, centered.
+
+Expected: spinner spins, label visible, full-page dark background.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/CodeShellManager/Assets/terminal.html src/CodeShellManager/Assets/terminal-transparent.html
+git commit -m "feat(spinner): add boot overlay markup + CSS to xterm host pages"
+```
+
+---
+
+### Task 2: Add `setBootState` / `bootDone` message handlers to `terminal-init.js`
+
+**Files:**
+- Modify: `src/CodeShellManager/Assets/terminal-init.js` (the existing `message` event listener block around lines 48–73)
+
+- [ ] **Step 1: Add handlers inside the existing WebView2 message listener**
+
+Find the `window.chrome.webview.addEventListener('message', e => { … })` block. Inside the `try { const msg = JSON.parse(e.data); … }` `if`/`else if` chain — after the existing `dropOverlayClear` handler and before the closing `} catch {}` — add two new `else if` branches:
+
+```javascript
+ else if (msg.type === 'setBootState') {
+ const label = document.getElementById('bootLabel');
+ const spinner = document.getElementById('bootSpinner');
+ if (label && typeof msg.label === 'string') label.textContent = msg.label;
+ if (spinner && typeof msg.accentHex === 'string') {
+ spinner.style.setProperty('--boot-accent', msg.accentHex);
+ }
+ }
+ else if (msg.type === 'bootDone') {
+ const overlay = document.getElementById('bootOverlay');
+ if (overlay && !overlay.classList.contains('hidden')) {
+ overlay.classList.add('hidden');
+ overlay.addEventListener('transitionend', () => {
+ try { overlay.parentNode && overlay.parentNode.removeChild(overlay); } catch {}
+ }, { once: true });
+ }
+ }
+```
+
+The `transitionend` listener uses `{ once: true }` so it auto-detaches after firing. The guard `!overlay.classList.contains('hidden')` makes `bootDone` idempotent — second invocation is a no-op.
+
+- [ ] **Step 2: Verify the JS file is syntactically valid**
+
+```bash
+node --check src/CodeShellManager/Assets/terminal-init.js
+```
+
+Expected: no output (silent success). If Node isn't installed, skip this check — the next task's build step will catch syntax errors via the WebView2 console at runtime.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/CodeShellManager/Assets/terminal-init.js
+git commit -m "feat(spinner): add setBootState/bootDone handlers in terminal-init.js"
+```
+
+---
+
+### Task 3: Bridge — add `SetBootContext`, post `setBootState`, set WebView2 default background
+
+**Files:**
+- Modify: `src/CodeShellManager/Terminal/TerminalBridge.cs`
+
+This task adds the API and one of the two posting paths. The second (post `bootDone` on first byte) lands in Task 4 together with the MainWindow wiring so the feature ships end-to-end in one commit.
+
+- [ ] **Step 1: Add boot context fields and `SetBootContext` method**
+
+In `src/CodeShellManager/Terminal/TerminalBridge.cs`, find the field declarations near the top of the class (after `_lastSize` at ~line 25 and before `_outputBuffer` at ~line 28). Add:
+
+```csharp
+ // Boot overlay — set by MainWindow before InitializeAsync; posted as setBootState after
+ // navigation completes, and hidden via bootDone on the first PTY byte (see OnPtyData).
+ private string? _bootLabel;
+ private string? _bootAccentHex;
+ private int _bootDoneFlag; // 0 = overlay still visible, 1 = bootDone already posted
+```
+
+Then, **after** the `public TerminalBridge(WebView2 webView)` constructor (~line 80), add the public API:
+
+```csharp
+ ///
+ /// Sets the boot-overlay label and accent color. Must be called before
+ /// — the bridge posts a setBootState message to the
+ /// page as soon as navigation completes.
+ ///
+ public void SetBootContext(string label, string accentHex)
+ {
+ _bootLabel = label;
+ _bootAccentHex = accentHex;
+ }
+```
+
+- [ ] **Step 2: Set WebView2 default background color before navigation**
+
+Inside `InitializeAsync`, **after** `await _webView.EnsureCoreWebView2Async(env);` (~line 96) and **before** `var settings = _webView.CoreWebView2.Settings;` (~line 99), add:
+
+```csharp
+ // Match the boot overlay background so the WebView2 init flicker (the gap between
+ // the control becoming visible and terminal.html rendering) is invisible.
+ try { _webView.DefaultBackgroundColor = System.Drawing.Color.FromArgb(0x1e, 0x1e, 0x2e); }
+ catch { }
+```
+
+Note: `WebView2.DefaultBackgroundColor` is on the WPF `WebView2` control itself (not `CoreWebView2Controller`), which is what's referenced in the spec — the WPF wrapper exposes it directly. Wrapped in try/catch because the property's availability historically varied across WebView2 SDK versions.
+
+- [ ] **Step 3: Post `setBootState` inside `NavCompleted`**
+
+Inside the existing `NavCompleted` local function in `InitializeAsync`, **after** `_ready = true;` and **before** the `// Flush any PTY output…` block, add:
+
+```csharp
+ // Apply boot-overlay state if MainWindow called SetBootContext before init.
+ if (_bootLabel != null && _bootAccentHex != null)
+ {
+ var bootJson = JsonSerializer.Serialize(new
+ {
+ type = "setBootState",
+ label = _bootLabel,
+ accentHex = _bootAccentHex
+ });
+ try { _webView.CoreWebView2?.PostWebMessageAsString(bootJson); }
+ catch { }
+ }
+```
+
+- [ ] **Step 4: Build the project**
+
+```bash
+dotnet build src/CodeShellManager/CodeShellManager.csproj
+```
+
+Expected: build succeeds with the pre-existing CS8123 warnings only. No new errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/CodeShellManager/Terminal/TerminalBridge.cs
+git commit -m "feat(spinner): add SetBootContext + post setBootState in TerminalBridge"
+```
+
+---
+
+### Task 4: Wire `bootDone` on first PTY byte + MainWindow visibility + SetBootContext call
+
+**Files:**
+- Modify: `src/CodeShellManager/Terminal/TerminalBridge.cs`
+- Modify: `src/CodeShellManager/MainWindow.xaml.cs`
+
+This is the task that makes the launching spinner work end-to-end.
+
+- [ ] **Step 1: Add a `PostBootDoneIfNeeded` helper in TerminalBridge**
+
+In `src/CodeShellManager/Terminal/TerminalBridge.cs`, near the `Trace` / `Log` helpers (around line 60), add:
+
+```csharp
+ // Posts a one-shot bootDone message to the WebView2. Safe to call from any thread.
+ private void PostBootDoneIfNeeded()
+ {
+ if (System.Threading.Interlocked.CompareExchange(ref _bootDoneFlag, 1, 0) != 0) return;
+ WpfApplication.Current?.Dispatcher.BeginInvoke(() =>
+ {
+ try { _webView.CoreWebView2?.PostWebMessageAsString("{\"type\":\"bootDone\"}"); }
+ catch { }
+ });
+ }
+```
+
+- [ ] **Step 2: Call `PostBootDoneIfNeeded` from `OnPtyData`**
+
+In the existing `OnPtyData` method (~line 180), find the line `RawOutputReceived?.Invoke(rawData);`. Immediately after it, add:
+
+```csharp
+ PostBootDoneIfNeeded();
+```
+
+Place it AFTER `RawOutputReceived` (so listeners see the raw bytes) and BEFORE the `!_ready` check (so even buffered output triggers the dismiss — once the page navigates the overlay will fade). The helper is idempotent so calling it repeatedly is safe.
+
+- [ ] **Step 3: Call `PostBootDoneIfNeeded` defensively from `Dispose`**
+
+In `TerminalBridge.Dispose` (~line 404), at the very start of the method body (before the `if (_pty != null)` line), add:
+
+```csharp
+ PostBootDoneIfNeeded();
+```
+
+This covers the case where a bridge is being torn down mid-launch — the page might still be alive momentarily before the WebView2 is reclaimed, and we don't want it to ship with a half-faded overlay if it somehow revives.
+
+- [ ] **Step 4: Build to verify the bridge changes compile**
+
+```bash
+dotnet build src/CodeShellManager/CodeShellManager.csproj
+```
+
+Expected: build succeeds, no new errors.
+
+- [ ] **Step 5: Call `SetBootContext` from `LaunchSessionAsync`**
+
+Open `src/CodeShellManager/MainWindow.xaml.cs`. Find `LaunchSessionAsync` — the method that creates `SessionViewModel`, `WebView2`, `TerminalBridge` and calls `bridge.InitializeAsync(htmlPath)`.
+
+Locate the line that calls `bridge.InitializeAsync(...)`. **Immediately before** that call, add:
+
+```csharp
+ string bootLabel = session.IsRemote
+ ? $"Connecting to {session.SshHost}…"
+ : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…";
+ bridge.SetBootContext(bootLabel, GetAccentForSession(session));
+```
+
+`GetAccentForSession` already exists in `MainWindow.xaml.cs` (used by `BuildLaunchingSidebarItem` at line 4101 — same accessor pattern).
+
+If you cannot locate the call to `bridge.InitializeAsync` quickly: search for `InitializeAsync(htmlPath` or `InitializeAsync(html` across `MainWindow.xaml.cs`. There should be one call site in `LaunchSessionAsync`.
+
+- [ ] **Step 6: Move `terminalWrapper.Visibility = Visibility.Visible` earlier**
+
+Still in `LaunchSessionAsync`. Currently the wrapper is made visible at MainWindow.xaml.cs:1083 — after the PTY is attached. Cut that line (`terminalWrapper.Visibility = Visibility.Visible;` and its trailing `Log(...)` line if there is one) from its current location, and paste it immediately after the line that adds the wrapper to `TerminalGrid.Children`.
+
+The exact target location: search for `TerminalGrid.Children.Add(terminalWrapper)` — set visibility on the next line. This makes the spinner visible from the moment the WebView2 host is in the layout, instead of after PTY spawn.
+
+If there's both a `Log("terminalWrapper visible, …")` line and the `Visibility = Visible` assignment, keep the Log near the original position (it'll fire after a successful PTY attach, which is still a meaningful event) but ensure the `Visibility = Visible` itself is moved to right after the `Children.Add`.
+
+- [ ] **Step 7: Build + run the app**
+
+```bash
+dotnet build src/CodeShellManager/CodeShellManager.csproj
+dotnet run --project src/CodeShellManager/CodeShellManager.csproj
+```
+
+In the app:
+1. Click + New Session. Pick any local command (e.g. `pwsh`).
+2. Observe: the new terminal pane should briefly show a rotating arc + "Starting pwsh…" label, then fade out as the prompt appears.
+3. Try creating an SSH session if you have access to one — label should read "Connecting to {host}…".
+4. Close the app for now (don't worry about the shutdown overlay yet — that lands in Task 6).
+
+If the spinner never appears: the wrapper visibility move didn't take. Re-check Step 6.
+If the spinner never disappears: `bootDone` isn't being posted. Check `OnPtyData` for the new line + verify `terminal-init.js` was saved with the handler.
+If the label is always "Initializing terminal…": `SetBootContext` isn't being called or the page navigates faster than the message ships — verify the call site in `LaunchSessionAsync` is before `InitializeAsync`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/CodeShellManager/Terminal/TerminalBridge.cs src/CodeShellManager/MainWindow.xaml.cs
+git commit -m "feat(spinner): show launching overlay until first PTY byte"
+```
+
+---
+
+### Task 5: Add `ShutdownOverlay` grid to `MainWindow.xaml`
+
+**Files:**
+- Modify: `src/CodeShellManager/MainWindow.xaml`
+
+- [ ] **Step 1: Find the root Grid's closing tag**
+
+Open `src/CodeShellManager/MainWindow.xaml`. The root content of the `` is a Grid (or DockPanel containing a Grid). Find the **last** `` before `` — the outermost layout container's closing tag.
+
+If the layout is nested (e.g. DockPanel wrapping a Grid wrapping more grids), the shutdown overlay should be a sibling at the topmost level so it can cover the entire window. The simplest target: if the root is a `` containing all UI, add this as the last child of that Grid (siblings render on top in Z-order).
+
+If you can't find a single root Grid, the safest fallback is to wrap the existing root in a new Grid with two children: the existing content and the `ShutdownOverlay`. Do this only if no clearer target exists — the file is unfamiliar.
+
+- [ ] **Step 2: Insert the overlay XAML**
+
+As the last child of the root Grid (or new outer Grid per fallback in Step 1), add:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Notes on the XAML:
+- `Background="#cc1e1e2e"` is `#1e1e2e` at 80% alpha — the existing UI shows through at low contrast.
+- `Panel.ZIndex="100"` is belt-and-braces; the overlay being the last child of the root Grid already puts it on top.
+- The `Storyboard` starts once when the Path is first loaded into the visual tree (at window startup) and runs for the lifetime of the window. A single `DoubleAnimation` on a rotation transform is cheap (<0.1% CPU) so we don't bother gating it on visibility — the simpler XAML is worth more than the marginal saving.
+
+- [ ] **Step 3: Build to confirm the XAML is valid**
+
+```bash
+dotnet build src/CodeShellManager/CodeShellManager.csproj
+```
+
+Expected: build succeeds. If XAML parsing fails, the compiler will pinpoint the line.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/CodeShellManager/MainWindow.xaml
+git commit -m "feat(spinner): add ShutdownOverlay grid to MainWindow.xaml"
+```
+
+---
+
+### Task 6: Wire shutdown overlay into `OnClosing`
+
+**Files:**
+- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (the existing `OnClosing` override at ~line 4789)
+
+- [ ] **Step 1: Show overlay and yield once**
+
+Open `src/CodeShellManager/MainWindow.xaml.cs`. Find `protected override async void OnClosing(...)` (~line 4789).
+
+After the existing `_isShuttingDown = true;` line (~line 4804) and **before** `_windowStateTimer.Stop();` (~line 4806), insert:
+
+```csharp
+ // Show the shutdown overlay so the user sees progress while sessions tear down.
+ // The yield lets WPF render the overlay before the synchronous disposal below blocks
+ // the UI thread; without it, the overlay would only paint after Close() is reached.
+ ShutdownOverlay.Visibility = Visibility.Visible;
+ await Dispatcher.InvokeAsync(() => { },
+ System.Windows.Threading.DispatcherPriority.Background);
+```
+
+The yield uses `DispatcherPriority.Background` (lower than `Render`), which guarantees a render pass completes before the continuation runs.
+
+- [ ] **Step 2: Build**
+
+```bash
+dotnet build src/CodeShellManager/CodeShellManager.csproj
+```
+
+Expected: build succeeds.
+
+- [ ] **Step 3: Manual verification**
+
+```bash
+dotnet run --project src/CodeShellManager/CodeShellManager.csproj
+```
+
+1. Open the app, create at least one session (more sessions = longer shutdown, more visible overlay).
+2. If you have Claude installed, open a Claude session too — it has a 10-second-per-session dispose path which makes the overlay shine.
+3. Close the window.
+4. Expected: the existing UI dims behind the `Shutting down…` overlay, the arc spins, and after sessions finish disposing the window actually closes.
+
+If the overlay never appears: the yield didn't fire before disposal started. Try increasing the priority to `ContextIdle` (lowest) or verify the XAML naming.
+If the overlay flashes too briefly to see: that's actually fine — it means shutdown was fast. Re-test with more sessions or a Claude session.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/CodeShellManager/MainWindow.xaml.cs
+git commit -m "feat(spinner): show ShutdownOverlay during OnClosing teardown"
+```
+
+---
+
+### Task 7: Final smoke test + run full test suite
+
+**Files:** None modified.
+
+- [ ] **Step 1: Run the full unit test suite**
+
+```bash
+dotnet test tests/CodeShellManager.Tests/CodeShellManager.Tests.csproj
+```
+
+Expected: all 206 tests pass (same as before the feature — no test changes were made).
+
+- [ ] **Step 2: End-to-end visual verification**
+
+```bash
+dotnet run --project src/CodeShellManager/CodeShellManager.csproj
+```
+
+Checklist:
+- [ ] New local session shows "Starting {command}…" spinner that fades out as the prompt arrives
+- [ ] New SSH session (if available) shows "Connecting to {host}…" spinner
+- [ ] Restored sessions on app launch each show their own spinner briefly
+- [ ] Failed launch (e.g. fake a typo'd command) removes the wrapper cleanly — no orphan spinner
+- [ ] Closing the app with sessions live shows "Shutting down…" overlay
+- [ ] Spinner arc color matches the session's accent stripe
+
+- [ ] **Step 3: If everything checks out, no commit needed**
+
+This task is verification only. If any item fails, return to the relevant earlier task and adjust.
+
+---
+
+## Out of scope (per spec)
+
+- Per-session sleep / wake / close spinners (only app-exit gets a spinner)
+- UI tests for the spinner (XAML/WebView2 makes this flaky; spec accepts manual verification)
+- Configurable spinner appearance (one accent-colored arc style everywhere)
+- Telemetry on how long launches actually take
diff --git a/docs/superpowers/specs/2026-05-16-session-spinners-design.md b/docs/superpowers/specs/2026-05-16-session-spinners-design.md
new file mode 100644
index 0000000..1bc69ca
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-16-session-spinners-design.md
@@ -0,0 +1,185 @@
+# Session Spinners — Design Spec
+
+**Date:** 2026-05-16
+**Status:** Approved
+**Scope:** Per-session "starting" spinner inside the xterm host, and an app-exit "shutting down" overlay on the main window. No per-session close/sleep spinner.
+
+---
+
+## Context
+
+When a session launches today, the terminal pane is invisible until the PTY has been spawned and `terminalWrapper.Visibility = Visible` is set at `MainWindow.xaml.cs:1083`. The sidebar shows a "launching…" placeholder (`_launchingSidebarItems`, `BuildLaunchingSidebarItem` at `MainWindow.xaml.cs:4099`), but the main terminal area gives no feedback. WebView2 init, navigation to `Assets/terminal.html`, xterm.js mount, and PTY spawn collectively take long enough — especially for SSH sessions waiting on host handshake — that users wonder whether anything is happening.
+
+App shutdown has a similar gap. With many live sessions, the WebView2 and PTY disposals serialize and can take a couple of seconds. The window currently freezes during that window with no indication of progress.
+
+This spec adds two small visual layers to cover both cases.
+
+---
+
+## Goals
+
+- Show a centered spinner + phase-appropriate text in the terminal pane from the moment a session begins launching until the first byte of PTY output arrives.
+- Show a full-window "Shutting down…" overlay during app exit while sessions are being torn down.
+- Style: accent-colored rotating arc, matches the per-session accent color from `ColorService`.
+- Hide automatically — no manual dismiss.
+
+## Non-goals
+
+- Sleep / wake / per-session close spinners. The dispose paths for those are fast enough that adding feedback would be more code than benefit.
+- A spinner on the sidebar entry. The existing `_launchingSidebarItems` placeholder already covers that surface and is not changing.
+- Progress *quantification* (e.g. "step 2 of 4"). The spinner is qualitative; only the label string changes between phases.
+- Surfacing the spinner inside run-command chips. Those have their own status UI.
+
+---
+
+## User flow
+
+**Launching a session:**
+
+1. User picks + New Session, fills in the dialog, hits OK. (Or session is restored on startup.)
+2. The terminal wrapper becomes visible immediately (no longer waiting until after PTY spawn).
+3. The wrapper shows a centered rotating arc in the session's accent color, with a phase label below:
+ - Local sessions: `Starting {command}…`
+ - SSH sessions: `Connecting to {host}…`
+ - During WebView2 init before the bridge has wired up: `Initializing terminal…` (default in HTML)
+4. As soon as the first byte of PTY output arrives, the overlay fades out (200ms) and the terminal content takes over.
+5. On launch failure, the existing catch block in `LaunchSessionAsync` already removes the terminal wrapper and shows a modal `MessageBox`. The spinner simply disappears with the wrapper — no separate error UI in the overlay. (Trade-off: a hung WebView2 init or SSH connect just spins forever; user must use the toolbar ✕ to bail out. Acceptable for v1; can be revisited if it happens in practice.)
+
+**App exit:**
+
+1. User clicks the window close button (or Alt+F4, etc.).
+2. A full-window overlay fades in (semi-transparent dark backdrop over the existing UI, centered spinner + `Shutting down…` label).
+3. Session disposals run on a background task.
+4. Once teardown completes, the window actually closes.
+
+---
+
+## Architecture
+
+### Per-session launch spinner (HTML + JS)
+
+The spinner lives inside the xterm host HTML, not as a WPF overlay. This means it disappears the instant `terminal-init.js` sees the first PTY data, with no cross-tier coordination needed.
+
+**`Assets/terminal.html` and `Assets/terminal-transparent.html`:**
+
+Both files get a sibling div alongside the existing xterm container:
+
+```html
+
+
+
Initializing terminal…
+
+```
+
+CSS lives in the same files (small enough not to warrant a separate stylesheet):
+
+- `.boot-overlay` — absolutely positioned, fills the WebView2 viewport, `background: #1e1e2e`, centered flex column, transitions `opacity 200ms`.
+- `.boot-spinner` — 48px square, rotates via `@keyframes spin` (1.2s linear infinite).
+- `.boot-arc` — stroked SVG arc (`stroke-dasharray`, `stroke-linecap: round`), default `stroke: #89b4fa`; overridable from JS by setting a CSS variable.
+- `.boot-label` — Catppuccin foreground (`#cdd6f4`), muted weight.
+- A `.boot-overlay.hidden` modifier sets `opacity: 0; pointer-events: none`, and a `transitionend` handler removes the node from the DOM.
+- `.boot-overlay.error` swaps the spinning arc for a static "!" glyph and stops the animation.
+
+**`Assets/terminal-init.js`:**
+
+Add three handlers on the existing WebView2 message channel:
+
+| Message | Payload | Effect |
+|---|---|---|
+| `setBootState` | `{ label: string, accentHex: string }` | Updates `#boot-label` text and the spinner CSS variable. |
+| `bootDone` | (none) | Adds `.hidden`; on `transitionend` removes the node. Idempotent — calling twice is safe. |
+
+### Per-session launch spinner (C# bridge)
+
+**`Terminal/TerminalBridge.cs`:**
+
+- Right after the WebView2 navigates to `terminal.html` and the bridge's `WebMessageReceived` is wired, post a `setBootState` message with the session's accent + phase label. The label is computed from `ShellSession.IsRemote` and the command line.
+- On the first invocation of `RawOutputReceived` for that session — track via a `bool _bootDone` field on the bridge — post `bootDone`.
+- Also post `bootDone` from `Dispose` defensively so a wrapper that's torn down mid-launch doesn't ship a half-faded overlay if it's somehow revived.
+- Ensure the WebView2's default background color is set to `#1e1e2e` before navigation (set on the `CoreWebView2Controller.DefaultBackgroundColor` in the existing WebView2 init code). This hides the gap between WebView2 becoming visible and `terminal.html` rendering.
+
+**`MainWindow.xaml.cs`:**
+
+- Move `terminalWrapper.Visibility = Visibility.Visible` from after the PTY-attach (~line 1083) to immediately after the wrapper is built. This makes the spinner visible during the full launch window. The existing catch block continues to remove the wrapper on PTY-start failure — no new error-UI logic needed.
+
+### App-exit overlay (WPF)
+
+**`MainWindow.xaml`:**
+
+A new `Grid x:Name="ShutdownOverlay"` at the end of the root grid (z-order on top), default `Visibility="Collapsed"`. Contents:
+
+- Full-bleed `Rectangle` with `Fill="#cc1e1e2e"` (80% alpha over existing UI).
+- Centered `StackPanel` with:
+ - A `Path` drawing the same arc shape as the HTML spinner, with a `Storyboard` rotating it 360° linear infinite. The storyboard is started in `Loaded` and stopped on `Unloaded` to avoid CPU when not visible.
+ - A `TextBlock` with `Shutting down…` in Catppuccin foreground.
+
+**`MainWindow.xaml.cs`:**
+
+Override `OnClosing`. WebView2 and PTY disposal must run on the UI thread, so we don't `Task.Run` the teardown — we yield once at `Background` priority to let the overlay paint, then dispose synchronously. The UI is still frozen during disposal, but the user now sees a "Shutting down…" overlay instead of a hung window.
+
+```
+private bool _shutdownInProgress;
+
+protected override async void OnClosing(CancelEventArgs e)
+{
+ if (_shutdownInProgress) return; // second pass: let base.OnClosing fall through naturally
+ e.Cancel = true;
+ _shutdownInProgress = true;
+
+ ShutdownOverlay.Visibility = Visibility.Visible;
+
+ // Yield once so the overlay actually paints before disposal blocks the UI thread.
+ await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
+
+ DisposeAllSessions(); // existing teardown extracted; runs on the UI thread.
+ Close(); // re-enters OnClosing with _shutdownInProgress=true.
+}
+```
+
+`DisposeAllSessions` is the existing teardown logic extracted from current shutdown paths — no behavior change. The yield-then-dispose pattern is enough to guarantee the overlay paints; we don't need true async disposal.
+
+---
+
+## Edge cases
+
+- **PTY launch fails:** Existing catch block removes the wrapper (and shows a `MessageBox`); the spinner disappears with the wrapper. No new error UI.
+- **WebView2 init flicker:** ~50–200ms before `terminal.html` renders. We set `CoreWebView2Controller.DefaultBackgroundColor` to `#1e1e2e` before navigation so the gap blends with the spinner overlay.
+- **Session restored on startup, multiple panes:** Each pane has its own independent overlay since each has its own bridge + WebView2. No coordination needed.
+- **SSH connection hangs forever:** Spinner spins forever. Existing close ✕ on the terminal toolbar still works since it's outside the WebView2.
+- **First PTY byte is ANSI clear-screen:** Still counts as "first output" — overlay hides. Acceptable; matches the user's mental model of "something happened."
+- **Re-entrant `OnClosing`:** Guarded by `_shutdownInProgress` flag; second pass falls through to base.
+- **App-exit while a session is mid-launch:** The launching wrapper's spinner becomes irrelevant once the shutdown overlay is on top. Disposal of the half-launched session works the same as today.
+
+---
+
+## Testing
+
+Most of the new code is XAML / HTML / JS and is not unit-testable headless. We rely on existing UI tests in `tests/CodeShellManager.UITests/` for smoke coverage:
+
+- A UI test in `SessionTests.cs` can assert the boot overlay element is visible briefly after creating a new session, then disappears once the prompt is on screen. FlaUI cannot directly query the WebView2 DOM, but it can assert the terminal wrapper is visible from t=0 (current behavior would have it invisible).
+- Shutdown overlay: add a UI test that closes the window with N sessions live and verifies the overlay element is present during teardown. May be flaky if teardown is faster than the polling interval — keep the assertion loose.
+
+No new unit tests planned. The C# changes in `TerminalBridge` are too tightly coupled to WebView2 messaging to test headlessly without a refactor we don't otherwise need.
+
+---
+
+## Out of scope (revisit later if asked)
+
+- A WPF-spinner pre-stage that hands off to the HTML spinner. Would eliminate the WebView2 init flicker, but the matched background color makes the gap invisible and it's not worth the extra coordination code.
+- Per-session sleep/close spinners.
+- Progress text more granular than the four phases listed above.
+- Spinner shape variations (we use one rotating arc everywhere).
+
+---
+
+## File touch list
+
+- `src/CodeShellManager/Assets/terminal.html` — overlay markup + CSS.
+- `src/CodeShellManager/Assets/terminal-transparent.html` — same.
+- `src/CodeShellManager/Assets/terminal-init.js` — `setBootState` / `bootDone` / `bootError` handlers.
+- `src/CodeShellManager/Terminal/TerminalBridge.cs` — post `setBootState` after navigation, `bootDone` on first PTY byte + defensively from `Dispose`; set WebView2 default background color before navigation.
+- `src/CodeShellManager/MainWindow.xaml` — `ShutdownOverlay` grid.
+- `src/CodeShellManager/MainWindow.xaml.cs` — `OnClosing` override + `DisposeAllSessions` extraction; move `terminalWrapper.Visibility = Visible` earlier in `LaunchSessionAsync`.
+- `tests/CodeShellManager.UITests/SessionTests.cs` — smoke tests for both overlays (best-effort).
diff --git a/installer/CodeShellManager.wxs b/installer/CodeShellManager.wxs
index 84d87b0..b664156 100644
--- a/installer/CodeShellManager.wxs
+++ b/installer/CodeShellManager.wxs
@@ -53,6 +53,9 @@
+
+
+
+
+ false
+
+
@@ -42,6 +53,10 @@
PreserveNewesttrue
+
+ PreserveNewest
+ true
+ PreserveNewesttrue
diff --git a/src/CodeShellManager/MainWindow.xaml b/src/CodeShellManager/MainWindow.xaml
index 7c3ee6b..e602ed5 100644
--- a/src/CodeShellManager/MainWindow.xaml
+++ b/src/CodeShellManager/MainWindow.xaml
@@ -66,6 +66,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs
index 610eef2..449e90a 100644
--- a/src/CodeShellManager/MainWindow.xaml.cs
+++ b/src/CodeShellManager/MainWindow.xaml.cs
@@ -225,11 +225,16 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
Log($"OnLoaded: {saved.Count} saved sessions, AutoRestore={_vm.Settings.AutoRestoreSessions}, CleanStart={App.CleanStart}");
if (App.CleanStart)
{
- // --clean: skip restore and leave state.json untouched. Drop the
- // saved-session list from the in-memory SessionManager so any new
- // work this run doesn't co-mingle with the persisted set.
+ // --clean: skip restore and leave state.json untouched. Drop sessions,
+ // groups, AND the recently-closed ring from in-memory state so any new
+ // work this run starts from a clean slate (no leftover scaffolding from
+ // prior debug sessions). SaveStateAsync is a no-op in --clean mode so
+ // these clears don't touch the persisted file.
foreach (var s in saved)
_sessionManager.RemoveSession(s.Id);
+ foreach (var g in _sessionManager.Groups.ToList())
+ _sessionManager.RemoveGroup(g.Id);
+ _vm.ClearRecentlyClosed();
return;
}
if (saved.Count == 0) return;
@@ -399,13 +404,28 @@ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? pa
_vm.Settings.LaunchCommands,
profiles,
defaultCommand: parent?.Session.Command,
- defaultArgs: parent?.Session.Args)
+ defaultArgs: parent?.Session.Args,
+ defaultName: null,
+ recentlyClosed: _vm.RecentlyClosed,
+ defaultSourceSession: parent?.Session)
{
Owner = this
};
if (dialog.ShowDialog() != true) return;
+ // If the user picked an entry from the "Recently closed" list, reopen that
+ // session directly with copied settings and skip the rest of the form.
+ if (dialog.SelectedRecentlyClosed != null)
+ {
+ var entry = dialog.SelectedRecentlyClosed;
+ // Only drop the entry from the ring after reopen succeeds — a transient
+ // launch failure (bad folder, SSH unavailable) would otherwise lose the
+ // entry permanently and the user couldn't retry.
+ _ = ReopenAndRemoveOnSuccessAsync(entry);
+ return;
+ }
+
// Group resolution priority:
// 1. Explicit selection from the dialog (currently unused — no group picker there)
// 2. Inherited from a parent session (spawn-near-parent flows)
@@ -436,12 +456,24 @@ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? pa
if (dialog.IsRemote)
{
- session.IsRemote = true;
+ session.Kind = Models.SessionKind.Ssh;
session.SshUser = dialog.SshUser;
session.SshHost = dialog.SshHost;
session.SshPort = dialog.SshPort;
session.SshRemoteFolder = dialog.SshRemoteFolder;
}
+ else if (dialog.IsWsl)
+ {
+ session.Kind = Models.SessionKind.Wsl;
+ session.WslDistro = dialog.WslDistro;
+ session.WslUser = dialog.WslUser;
+ session.WslWorkingFolder = dialog.WslWorkingFolder;
+ // The session's WorkingFolder stays as a Windows UNC view of the same path
+ // so anything that touches the filesystem (git status, "open in Explorer")
+ // resolves correctly. Empty = unmounted; LaunchSessionAsync falls back.
+ session.WorkingFolder = Services.WslDiscoveryService.ToUncPath(
+ dialog.WslDistro, dialog.WslWorkingFolder);
+ }
// Profile overrides come from the dialog (which may have copied from a Windows Terminal
// profile). When the dialog left them blank and we have a parent, inherit the parent's.
@@ -459,6 +491,75 @@ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? pa
_ = LaunchAndFollowUpWorktreesAsync(session, dialog.AdditionalWorktreePaths);
}
+ ///
+ /// Recreates a session from a snapshot. Gets a fresh
+ /// Id (so it's independent of the original) and goes through the normal launch path.
+ /// Returns the created session; callers can verify it remained in
+ /// _sessionManager.Sessions after the await to confirm launch success.
+ ///
+ private async Task ReopenClosedSessionAsync(RecentlyClosedEntry entry)
+ {
+ var session = _sessionManager.CreateSession(
+ entry.Name,
+ entry.WorkingFolder,
+ entry.Command,
+ entry.Args,
+ string.IsNullOrEmpty(entry.GroupId) ? null : entry.GroupId,
+ colorOverride: entry.ColorOverride);
+
+ // Kind first so the IsRemote shim below doesn't promote a Wsl entry back
+ // to Ssh when its IsRemote happens to round-trip as false.
+ session.Kind = entry.Kind;
+ // Legacy entries (pre-Kind) have Kind=Local but IsRemote=true for SSH —
+ // the IsRemote setter on ShellSession migrates that to Kind=Ssh.
+ if (entry.Kind == Models.SessionKind.Local) session.IsRemote = entry.IsRemote;
+ session.SshUser = entry.SshUser;
+ session.SshHost = entry.SshHost;
+ session.SshPort = entry.SshPort;
+ session.SshRemoteFolder = entry.SshRemoteFolder;
+ session.WslDistro = entry.WslDistro;
+ session.WslUser = entry.WslUser;
+ session.WslWorkingFolder = entry.WslWorkingFolder;
+
+ session.ProfileFontFamily = entry.ProfileFontFamily;
+ session.ProfileFontSize = entry.ProfileFontSize;
+ session.ProfileFontWeight = entry.ProfileFontWeight;
+ session.ProfileFontLigatures = entry.ProfileFontLigatures;
+ session.ProfileCursorShape = entry.ProfileCursorShape;
+ session.ProfileCursorBlink = entry.ProfileCursorBlink;
+ session.ProfilePadding = entry.ProfilePadding;
+ session.ProfileBackgroundOpacity = entry.ProfileBackgroundOpacity;
+ session.ProfileRetroEffect = entry.ProfileRetroEffect;
+ session.ProfileColorSchemeJson = entry.ProfileColorSchemeJson;
+
+ // Deep-copy RunCommands so subsequent edits don't mutate any other entry
+ // that may still share the same list reference.
+ session.RunCommands = entry.RunCommands.Select(r => new RunCommandItem
+ {
+ Id = Guid.NewGuid().ToString(),
+ Label = r.Label,
+ CommandLine = r.CommandLine,
+ IsDefault = r.IsDefault,
+ Mode = r.Mode,
+ PostRunUrl = r.PostRunUrl,
+ }).ToList();
+
+ await LaunchSessionAsync(session);
+ return session;
+ }
+
+ ///
+ /// Reopens a recently-closed entry and only drops it from the ring if the launch
+ /// actually succeeded. LaunchSessionAsync's catch path removes the session it
+ /// created on failure, so we use SessionManager membership as the success signal.
+ ///
+ private async Task ReopenAndRemoveOnSuccessAsync(RecentlyClosedEntry entry)
+ {
+ var session = await ReopenClosedSessionAsync(entry);
+ if (_sessionManager.Sessions.Any(s => s.Id == session.Id))
+ _vm.RemoveRecentlyClosed(entry);
+ }
+
///
/// Launches the primary session, then any opt-in sibling worktrees from the dialog —
/// each inheriting the primary's command, group, and profile overrides, and inserted
@@ -489,6 +590,7 @@ private async Task LaunchAndFollowUpWorktreesAsync(ShellSession primary, IReadOn
string.IsNullOrEmpty(primary.GroupId) ? null : primary.GroupId,
colorOverride: null,
afterSessionId: anchorId);
+ InheritSessionKindFrom(sibling, primary);
// Inherit profile so siblings look identical.
sibling.ProfileFontFamily = primary.ProfileFontFamily;
sibling.ProfileFontSize = primary.ProfileFontSize;
@@ -523,14 +625,7 @@ private async Task DuplicateSessionAsync(SessionViewModel parent)
string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId,
colorOverride: null,
afterSessionId: parent.Id);
- if (p.IsRemote)
- {
- clone.IsRemote = true;
- clone.SshUser = p.SshUser;
- clone.SshHost = p.SshHost;
- clone.SshPort = p.SshPort;
- clone.SshRemoteFolder = p.SshRemoteFolder;
- }
+ InheritSessionKindFrom(clone, p);
clone.ProfileFontFamily = p.ProfileFontFamily;
clone.ProfileFontSize = p.ProfileFontSize;
clone.ProfileFontWeight = p.ProfileFontWeight;
@@ -550,6 +645,8 @@ private async Task DuplicateSessionAsync(SessionViewModel parent)
Label = item.Label,
CommandLine = item.CommandLine,
IsDefault = item.IsDefault,
+ Mode = item.Mode,
+ PostRunUrl = item.PostRunUrl,
});
}
// If the parent had no commands, fall back to detection.
@@ -573,6 +670,55 @@ private string DeriveDuplicateName(string baseName)
return $"{stem} ({start})";
}
+ ///
+ /// Propagates a parent session's and kind-specific
+ /// fields (SSH host/user/port, WSL distro/user) onto a freshly-created child
+ /// session. For WSL children it also derives WslWorkingFolder from the
+ /// child's WorkingFolder, which the worktree code paths set to a
+ /// \\wsl$\<distro>\… UNC. Without this step a new session spawned
+ /// from a WSL parent (Duplicate, sibling worktree, new worktree) silently falls
+ /// back to and tries to run the parent's
+ /// command (e.g. claude) inside a Windows PowerShell at the UNC path.
+ ///
+ private static void InheritSessionKindFrom(Models.ShellSession target, Models.ShellSession source)
+ {
+ target.Kind = source.Kind;
+ if (source.Kind == Models.SessionKind.Ssh)
+ {
+ target.SshUser = source.SshUser;
+ target.SshHost = source.SshHost;
+ target.SshPort = source.SshPort;
+ target.SshRemoteFolder = source.SshRemoteFolder;
+ return;
+ }
+ if (source.Kind == Models.SessionKind.Wsl)
+ {
+ target.WslDistro = source.WslDistro;
+ target.WslUser = source.WslUser;
+
+ var (parsedDistro, parsedLinux) = Services.GitService.TryParseWslUnc(target.WorkingFolder);
+ if (!string.IsNullOrEmpty(parsedDistro))
+ {
+ // Common path: WorkingFolder is a WSL UNC the caller already built.
+ target.WslWorkingFolder = parsedLinux == "/" ? "" : parsedLinux;
+ }
+ else if (!string.IsNullOrEmpty(target.WorkingFolder) && target.WorkingFolder.StartsWith('/'))
+ {
+ // Caller passed a Linux path directly (e.g. typed into a worktree dialog).
+ target.WslWorkingFolder = target.WorkingFolder;
+ target.WorkingFolder = Services.WslDiscoveryService.ToUncPath(
+ source.WslDistro, target.WslWorkingFolder);
+ }
+ else
+ {
+ // Unknown shape — keep the parent's folder so the child at least lands
+ // somewhere usable instead of in $HOME-by-accident.
+ target.WslWorkingFolder = source.WslWorkingFolder;
+ target.WorkingFolder = source.WorkingFolder;
+ }
+ }
+ }
+
///
/// Launches a new session in an existing sibling worktree (path resolved via
/// `git worktree list`). Inherits the source session's command, group, and profile.
@@ -594,6 +740,7 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent,
string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId,
colorOverride: null,
afterSessionId: parent.Id);
+ InheritSessionKindFrom(sibling, p);
sibling.ProfileFontFamily = p.ProfileFontFamily;
sibling.ProfileFontSize = p.ProfileFontSize;
sibling.ProfileFontWeight = p.ProfileFontWeight;
@@ -616,7 +763,12 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent,
///
private void SeedRunCommandsAsync(Models.ShellSession session)
{
- if (session.IsRemote) return;
+ // SSH is out of reach for the synchronous Directory.EnumerateFiles probe.
+ // WSL is reachable via the `\\wsl$\\…` UNC view — slow on first
+ // access if the distro VM is stopped, but the probe runs on a background
+ // task so the UI doesn't block. RunInstance already wraps run commands in
+ // `wsl.exe -- bash -lc` for WSL parents.
+ if (session.Kind == Models.SessionKind.Ssh) return;
if (session.RunCommands.Count > 0) return;
if (string.IsNullOrWhiteSpace(session.WorkingFolder)) return;
@@ -886,6 +1038,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
var terminalWrapper = BuildTerminalWrapper(vm, webView);
terminalWrapper.Visibility = Visibility.Collapsed;
TerminalGrid.Children.Add(terminalWrapper); // in tree → WebView2 can init
+ terminalWrapper.Visibility = Visibility.Visible; // show spinner immediately
// Create bridge and initialize
var bridge = new TerminalBridge(webView);
@@ -918,6 +1071,10 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html";
string htmlPath = new Uri(Path.Combine(assetsDir, htmlFile)).AbsoluteUri;
+ string bootLabel = session.IsRemote
+ ? $"Connecting to {session.SshHost}…"
+ : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…";
+ bridge.SetBootContext(bootLabel, GetAccentForSession(session));
await bridge.InitializeAsync(htmlPath);
bridge.ApplyFontSettings(_vm.Settings);
bridge.ApplyProfileOverrides(session);
@@ -951,12 +1108,21 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
string effectiveArgs;
string workDir;
- if (session.IsRemote)
+ if (session.Kind == Models.SessionKind.Ssh)
{
effectiveCommand = "ssh";
effectiveArgs = session.BuildSshArgs();
workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
+ else if (session.Kind == Models.SessionKind.Wsl)
+ {
+ // wsl.exe handles its own cwd via --cd inside BuildWslArgs; pass the user
+ // profile as the launching process's cwd so CreateProcess never sees a UNC
+ // path it might reject.
+ effectiveCommand = "wsl.exe";
+ effectiveArgs = session.BuildWslArgs();
+ workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ }
else
{
workDir = Directory.Exists(session.WorkingFolder)
@@ -1022,7 +1188,6 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
}
Log($"terminalWrapper visible, TerminalGrid children={TerminalGrid.Children.Count}");
- terminalWrapper.Visibility = Visibility.Visible;
// Build sidebar entry
var sidebarItem = BuildSidebarItem(vm);
@@ -2663,6 +2828,13 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM
var psItem = new System.Windows.Controls.MenuItem { Header = "Open PowerShell here" };
psItem.Click += (_, _) => LaunchPowerShellInFolder(vm.WorkingFolder, vm.GroupId);
menu.Items.Add(psItem);
+
+ if (vm.Session.IsWsl)
+ {
+ var wslConsoleItem = new System.Windows.Controls.MenuItem { Header = "Open WSL console here" };
+ wslConsoleItem.Click += (_, _) => LaunchWslConsoleFromSession(vm.Session);
+ menu.Items.Add(wslConsoleItem);
+ }
}
menu.Items.Add(new System.Windows.Controls.Separator());
@@ -2850,6 +3022,7 @@ private async Task OpenNewWorktreeDialogAsync(SessionViewModel source)
source.Session.Args,
string.IsNullOrEmpty(source.Session.GroupId) ? null : source.Session.GroupId,
source.Session.ColorOverride);
+ InheritSessionKindFrom(newSession, source.Session);
newSession.ProfileFontFamily = source.Session.ProfileFontFamily;
newSession.ProfileFontSize = source.Session.ProfileFontSize;
newSession.ProfileFontWeight = source.Session.ProfileFontWeight;
@@ -3300,6 +3473,7 @@ private void UpdateAlertBadge()
private void Layout_Six_Click(object s, RoutedEventArgs e) => SetLayout(LayoutMode.SixColumn);
private void Layout_SixTwo_Click(object s, RoutedEventArgs e) => SetLayout(LayoutMode.SixByTwo);
private void Layout_SixThree_Click(object s, RoutedEventArgs e) => SetLayout(LayoutMode.SixByThree);
+ private void Layout_ThreeByThree_Click(object s, RoutedEventArgs e) => SetLayout(LayoutMode.ThreeByThree);
private void SetLayout(LayoutMode mode)
{
@@ -3417,6 +3591,15 @@ private void RefreshTerminalLayout()
break;
}
+ case LayoutMode.ThreeByThree:
+ {
+ var view = GetViewportSessions(sessions, 9);
+ for (int i = 0; i < 3; i++) TerminalGrid.ColumnDefinitions.Add(new ColumnDefinition());
+ for (int r = 0; r < 3; r++) TerminalGrid.RowDefinitions.Add(new RowDefinition());
+ for (int i = 0; i < 9; i++) PlaceTerminal(view, i, i / 3, i % 3);
+ break;
+ }
+
default: // Single
{
// If the active session is filtered out by the group filter, fall back
@@ -4061,9 +4244,7 @@ private Border BuildLaunchingSidebarItem(ShellSession session)
var textPanel = new StackPanel { Margin = new Thickness(8, 6, 4, 6) };
string displayName = string.IsNullOrWhiteSpace(session.Name)
- ? (session.IsRemote
- ? (string.IsNullOrWhiteSpace(session.SshHost) ? session.Command : session.SshHost)
- : System.IO.Path.GetFileName(session.WorkingFolder.TrimEnd('/', '\\')) ?? session.Command)
+ ? session.DefaultDisplayName
: session.Name;
var nameText = new TextBlock
@@ -4075,11 +4256,7 @@ private Border BuildLaunchingSidebarItem(ShellSession session)
TextTrimming = TextTrimming.CharacterEllipsis
};
- string folderShort = session.IsRemote
- ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost)
- : (string.IsNullOrEmpty(session.WorkingFolder)
- ? ""
- : new System.IO.DirectoryInfo(session.WorkingFolder).Name);
+ string folderShort = session.FolderShort;
var folderText = new TextBlock
{
@@ -4156,9 +4333,7 @@ private Border BuildDormantSidebarItem(ShellSession session)
var textPanel = new StackPanel { Margin = new Thickness(8, 6, 4, 6) };
string displayName = string.IsNullOrWhiteSpace(session.Name)
- ? (session.IsRemote
- ? (string.IsNullOrWhiteSpace(session.SshHost) ? session.Command : session.SshHost)
- : System.IO.Path.GetFileName(session.WorkingFolder.TrimEnd('/', '\\')) ?? session.Command)
+ ? session.DefaultDisplayName
: session.Name;
var nameText = new TextBlock
@@ -4170,11 +4345,7 @@ private Border BuildDormantSidebarItem(ShellSession session)
TextTrimming = TextTrimming.CharacterEllipsis
};
- string folderShort = session.IsRemote
- ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost)
- : (string.IsNullOrEmpty(session.WorkingFolder)
- ? ""
- : new System.IO.DirectoryInfo(session.WorkingFolder).Name);
+ string folderShort = session.FolderShort;
var folderText = new TextBlock
{
@@ -4262,10 +4433,7 @@ private static bool IsDescendantOf(System.Windows.DependencyObject node, System.
}
private static string GetAccentForSession(ShellSession s) =>
- s.ColorOverride ?? ColorService.GetHexColor(
- s.IsRemote
- ? (string.IsNullOrWhiteSpace(s.SshUser) ? s.SshHost : $"{s.SshUser}@{s.SshHost}")
- : s.WorkingFolder);
+ s.ColorOverride ?? ColorService.GetHexColor(s.AccentKey);
// ── Search ────────────────────────────────────────────────────────────────
@@ -4454,6 +4622,27 @@ private void LaunchPowerShellInFolder(string workingFolder, string groupId)
_ = LaunchSessionAsync(session);
}
+ ///
+ /// WSL counterpart of : spawns a bare bash
+ /// session inside the same distro + Linux folder as .
+ /// Used by the "Open WSL console here" context-menu item.
+ ///
+ private void LaunchWslConsoleFromSession(Models.ShellSession parent)
+ {
+ if (!parent.IsWsl) return;
+ string leaf = string.IsNullOrEmpty(parent.WslWorkingFolder)
+ ? parent.WslDistro
+ : System.IO.Path.GetFileName(parent.WslWorkingFolder.TrimEnd('/'));
+ string name = string.IsNullOrEmpty(leaf) ? "bash" : $"{leaf} (bash)";
+
+ var session = _sessionManager.CreateSession(name, parent.WorkingFolder, "bash", "", parent.GroupId);
+ session.Kind = Models.SessionKind.Wsl;
+ session.WslDistro = parent.WslDistro;
+ session.WslUser = parent.WslUser;
+ session.WslWorkingFolder = parent.WslWorkingFolder;
+ _ = LaunchSessionAsync(session);
+ }
+
private static bool ExistsOnPath(string executable)
{
try
@@ -4661,7 +4850,18 @@ private void OnBridgeAcceleratorKey(object? sender, WpfKeyEventArgs e)
private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods)
{
if (key == Key.T && mods == ModifierKeys.Control) { OpenNewSessionDialog(); return true; }
+ // Browser convention: Ctrl+Shift+T reopens the most-recently-closed session.
+ // Duplicate moved to Ctrl+Alt+T to free this slot.
if (key == Key.T && mods == (ModifierKeys.Control | ModifierKeys.Shift))
+ {
+ // Peek instead of pop — if the reopen fails, the entry stays available
+ // for retry. PeekMostRecentlyClosed returns the same entry PopMostRecently
+ // would have popped; ReopenAndRemoveOnSuccessAsync removes only on success.
+ var entry = _vm.PeekMostRecentlyClosed();
+ if (entry != null) _ = ReopenAndRemoveOnSuccessAsync(entry);
+ return true;
+ }
+ if (key == Key.T && mods == (ModifierKeys.Control | ModifierKeys.Alt))
{
if (_vm.ActiveSession != null) _ = DuplicateSessionAsync(_vm.ActiveSession);
return true;
@@ -4727,6 +4927,13 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
if (_isShuttingDown) return;
_isShuttingDown = true;
+ // Show the shutdown overlay so the user sees progress while sessions tear down.
+ // The yield lets WPF render the overlay before the synchronous disposal below blocks
+ // the UI thread; without it, the overlay would only paint after Close() is reached.
+ ShutdownOverlay.Visibility = Visibility.Visible;
+ await Dispatcher.InvokeAsync(() => { },
+ System.Windows.Threading.DispatcherPriority.Background);
+
_windowStateTimer.Stop();
if (_windowStateReady)
_vm.UpdateWindowState(WindowState, Left, Top, Width, Height);
diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs
index 1d19592..f1a1dc5 100644
--- a/src/CodeShellManager/Models/AppState.cs
+++ b/src/CodeShellManager/Models/AppState.cs
@@ -140,6 +140,12 @@ public class AppState
{
public List Sessions { get; set; } = [];
public List Groups { get; set; } = [];
+ ///
+ /// Ring buffer of recently-closed sessions (newest first), capped at
+ /// . Reopen via
+ /// Ctrl+Shift+T or the "Recently closed" list in the New Session dialog.
+ ///
+ public List RecentlyClosed { get; set; } = [];
public string LastLayout { get; set; } = "Single";
///
/// Per-group grid layouts when is on.
diff --git a/src/CodeShellManager/Models/RecentlyClosedEntry.cs b/src/CodeShellManager/Models/RecentlyClosedEntry.cs
new file mode 100644
index 0000000..18489e3
--- /dev/null
+++ b/src/CodeShellManager/Models/RecentlyClosedEntry.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace CodeShellManager.Models;
+
+///
+/// A snapshot of a recently-closed session, kept so the user can reopen it with
+/// Ctrl+Shift+T or via the "Recently closed" list in the New Session dialog.
+/// Stored on (capped — see
+/// MainViewModel.MaxRecentlyClosed) and persisted to state.json.
+///
+/// Deliberately separate from so PTY/runtime fields
+/// (IsDormant, Status, LastActivityAt) don't leak into the ring buffer and so the
+/// model can evolve independently.
+///
+public class RecentlyClosedEntry
+{
+ public string Name { get; set; } = "";
+ public string WorkingFolder { get; set; } = "";
+ public string Command { get; set; } = "claude";
+ public string Args { get; set; } = "";
+ public string GroupId { get; set; } = "";
+ public string? ColorOverride { get; set; }
+
+ ///
+ /// Kind of the closed session — needed so a reopened WSL session comes back
+ /// as WSL instead of falling back to Local at the UNC path. Mirrors the
+ /// migration: setting
+ /// to true promotes Local → Ssh, so legacy state.json entries (which
+ /// only carried IsRemote) still display the right subtitle and reopen as SSH.
+ ///
+ public SessionKind Kind { get; set; } = SessionKind.Local;
+
+ public bool IsRemote
+ {
+ get => Kind == SessionKind.Ssh;
+ set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; }
+ }
+ public string SshUser { get; set; } = "";
+ public string SshHost { get; set; } = "";
+ public int SshPort { get; set; } = 22;
+ public string SshRemoteFolder { get; set; } = "";
+
+ public string WslDistro { get; set; } = "";
+ public string WslUser { get; set; } = "";
+ public string WslWorkingFolder { get; set; } = "";
+
+ public string? ProfileFontFamily { get; set; }
+ public int? ProfileFontSize { get; set; }
+ public string? ProfileFontWeight { get; set; }
+ public bool? ProfileFontLigatures { get; set; }
+ public string? ProfileCursorShape { get; set; }
+ public bool? ProfileCursorBlink { get; set; }
+ public string? ProfilePadding { get; set; }
+ public double? ProfileBackgroundOpacity { get; set; }
+ public bool? ProfileRetroEffect { get; set; }
+ public string? ProfileColorSchemeJson { get; set; }
+
+ public List RunCommands { get; set; } = new();
+
+ public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Shallow copies the persistable fields of a .
+ /// RunCommands are deep-copied with fresh Ids so editing the reopened session
+ /// can't accidentally mutate the entry still in the ring buffer.
+ ///
+ public static RecentlyClosedEntry FromSession(ShellSession s) => new()
+ {
+ Name = s.Name,
+ WorkingFolder = s.WorkingFolder,
+ Command = s.Command,
+ Args = s.Args,
+ GroupId = s.GroupId,
+ ColorOverride = s.ColorOverride,
+ Kind = s.Kind,
+ IsRemote = s.IsRemote,
+ SshUser = s.SshUser,
+ SshHost = s.SshHost,
+ SshPort = s.SshPort,
+ SshRemoteFolder = s.SshRemoteFolder,
+ WslDistro = s.WslDistro,
+ WslUser = s.WslUser,
+ WslWorkingFolder = s.WslWorkingFolder,
+ ProfileFontFamily = s.ProfileFontFamily,
+ ProfileFontSize = s.ProfileFontSize,
+ ProfileFontWeight = s.ProfileFontWeight,
+ ProfileFontLigatures = s.ProfileFontLigatures,
+ ProfileCursorShape = s.ProfileCursorShape,
+ ProfileCursorBlink = s.ProfileCursorBlink,
+ ProfilePadding = s.ProfilePadding,
+ ProfileBackgroundOpacity = s.ProfileBackgroundOpacity,
+ ProfileRetroEffect = s.ProfileRetroEffect,
+ ProfileColorSchemeJson = s.ProfileColorSchemeJson,
+ RunCommands = s.RunCommands.Select(r => new RunCommandItem
+ {
+ Id = Guid.NewGuid().ToString(),
+ Label = r.Label,
+ CommandLine = r.CommandLine,
+ IsDefault = r.IsDefault,
+ Mode = r.Mode,
+ PostRunUrl = r.PostRunUrl,
+ }).ToList(),
+ ClosedAt = DateTime.UtcNow,
+ };
+
+ /// Friendly subtitle for the recents UI — kind-specific locator.
+ public string Subtitle => Kind switch
+ {
+ SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}",
+ SessionKind.Wsl => string.IsNullOrEmpty(WslWorkingFolder)
+ ? WslDistro
+ : $"{WslDistro}: {WslWorkingFolder}",
+ _ => WorkingFolder,
+ };
+}
diff --git a/src/CodeShellManager/Models/RunCommandItem.cs b/src/CodeShellManager/Models/RunCommandItem.cs
index e808ffe..308e588 100644
--- a/src/CodeShellManager/Models/RunCommandItem.cs
+++ b/src/CodeShellManager/Models/RunCommandItem.cs
@@ -3,6 +3,19 @@
namespace CodeShellManager.Models;
+///
+/// How the run-command's command line is launched.
+/// Process: cmd /c "" — bare ConPTY child, the historical default.
+/// PowerShell: pwsh.exe -EncodedCommand (falls back to powershell.exe if pwsh is missing)
+/// needed when the command relies on pipes, $env:, cmdlets, or other PS syntax.
+/// SSH parents ignore this — remote runs always go through bash.
+///
+public enum RunMode
+{
+ Process,
+ PowerShell,
+}
+
///
/// One configured "run" command on a session. The user can have many of these;
/// exactly one is the default (driven by the toolbar ▶ button and F5 keybinding).
@@ -15,6 +28,15 @@ public class RunCommandItem
public string CommandLine { get; set; } = "";
public bool IsDefault { get; set; }
+ /// Host process for the command. Defaults to Process for back-compat with pre-existing state.json entries.
+ public RunMode Mode { get; set; } = RunMode.Process;
+
+ ///
+ /// Optional URL opened in the default browser when the command exits with code 0.
+ /// Null / empty disables. Invoked via ShellExecute, so the OS handles validation.
+ ///
+ public string? PostRunUrl { get; set; }
+
///
/// Normalizes the list so exactly one item has IsDefault=true (when non-empty).
/// If multiple are marked, the LAST one wins — this matches the editor dialog's
diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs
index de82a4b..f93e5b0 100644
--- a/src/CodeShellManager/Models/ShellSession.cs
+++ b/src/CodeShellManager/Models/ShellSession.cs
@@ -6,6 +6,13 @@ namespace CodeShellManager.Models;
public enum SessionStatus { Idle, Running, NeedsAttention, Exited }
+///
+/// Kind of pseudo-terminal session. runs a Windows process directly,
+/// tunnels through the system ssh client, launches
+/// a shell inside a WSL distro via wsl.exe.
+///
+public enum SessionKind { Local, Ssh, Wsl }
+
public class ShellSession
{
public string Id { get; set; } = Guid.NewGuid().ToString();
@@ -34,13 +41,44 @@ public class ShellSession
///
public bool IsDormant { get; set; }
+ ///
+ /// Authoritative session kind. New code reads this; is kept
+ /// as a back-compat shim so legacy state.json (which only carried the SSH boolean)
+ /// continues to deserialize: on load, IsRemote=true promotes Kind to
+ /// .
+ ///
+ public SessionKind Kind { get; set; } = SessionKind.Local;
+
// SSH / remote session fields
- public bool IsRemote { get; set; }
+ ///
+ /// SSH flag — true iff is .
+ /// Kept as a property (not just a computed getter) so old state.json files with
+ /// "IsRemote": true and no Kind key still migrate cleanly on
+ /// deserialization. The setter only promotes Local → Ssh; it never clears
+ /// Kind, so a JSON document with both IsRemote and Kind
+ /// (deserialized in any order) lands on the correct value.
+ ///
+ public bool IsRemote
+ {
+ get => Kind == SessionKind.Ssh;
+ set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; }
+ }
+
+ /// True iff this session runs inside a WSL distro via wsl.exe.
+ public bool IsWsl => Kind == SessionKind.Wsl;
public string SshUser { get; set; } = "";
public string SshHost { get; set; } = "";
public int SshPort { get; set; } = 22;
public string SshRemoteFolder { get; set; } = "";
+ // WSL session fields
+ /// Name of the WSL distro (matches wsl -l -q), e.g. "Ubuntu".
+ public string WslDistro { get; set; } = "";
+ /// Optional WSL user override (wsl -u <user>). Empty = the distro's default user.
+ public string WslUser { get; set; } = "";
+ /// Linux-style working folder inside the distro, e.g. "/home/alice/project". Empty = the user's home.
+ public string WslWorkingFolder { get; set; } = "";
+
// Per-session appearance overrides (typically populated from a Windows
// Terminal profile via NewSessionDialog). All nullable — null means "use the
// global terminal settings". Persisted to state.json so a session relaunches
@@ -66,11 +104,12 @@ public class ShellSession
public List RunCommands { get; set; } = new();
// Full command line for display and passthrough.
- // For remote sessions: "ssh "
- // For local sessions: "Command [Args]"
- public string FullCommandLine => IsRemote
- ? $"ssh {BuildSshArgs()}"
- : (string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}");
+ public string FullCommandLine => Kind switch
+ {
+ SessionKind.Ssh => $"ssh {BuildSshArgs()}",
+ SessionKind.Wsl => $"wsl.exe {BuildWslArgs()}",
+ _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}",
+ };
///
/// Builds the argument string passed to the ssh executable.
@@ -96,4 +135,93 @@ internal string BuildSshArgs()
sb.Append("\"");
return sb.ToString();
}
+
+ ///
+ /// Builds the argument string passed to wsl.exe.
+ /// Example: "-d Ubuntu -u alice --cd /home/alice/project -- bash -lc \"claude\""
+ /// The command is wrapped in bash -lc so PATH-resolved tools (nvm-managed
+ /// node, pyenv, etc.) work the same as in a user-launched login shell. Distro,
+ /// user, and working-folder values are passed through
+ /// so values containing spaces (Linux paths often do) survive Win32 arg parsing.
+ ///
+ internal string BuildWslArgs()
+ {
+ if (string.IsNullOrWhiteSpace(WslDistro))
+ throw new InvalidOperationException("WslDistro must be set for WSL sessions.");
+ var sb = new StringBuilder();
+ sb.Append($"-d {QuoteForCmd(WslDistro)}");
+ if (!string.IsNullOrWhiteSpace(WslUser))
+ sb.Append($" -u {QuoteForCmd(WslUser)}");
+ if (!string.IsNullOrWhiteSpace(WslWorkingFolder))
+ sb.Append($" --cd {QuoteForCmd(WslWorkingFolder)}");
+ var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command;
+ string inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}";
+ sb.Append($" -- bash -lc \"{inner.Replace("\"", "\\\"")}\"");
+ return sb.ToString();
+ }
+
+ ///
+ /// Conservative Win32 command-line quoting: leaves space-free, quote-free values
+ /// alone (so existing call sites and tests don't regress) and wraps anything else
+ /// in double quotes with embedded " escaped as \". Used by the WSL
+ /// arg builders (here and in RunInstance) and GitService's wsl.exe routing.
+ ///
+ internal static string QuoteForCmd(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return "\"\"";
+ if (value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return value;
+ return "\"" + value.Replace("\"", "\\\"") + "\"";
+ }
+
+ // ── Display helpers (single source of truth — see MainWindow sidebar / VM) ────
+
+ ///
+ /// Subtitle-line text for the sidebar: a short, kind-appropriate locator.
+ /// Local → working folder leaf; Ssh → host; Wsl → distro:linux-leaf.
+ ///
+ public string FolderShort => Kind switch
+ {
+ SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "" : SshHost,
+ SessionKind.Wsl => BuildWslFolderShort(),
+ _ => string.IsNullOrEmpty(WorkingFolder)
+ ? ""
+ : new System.IO.DirectoryInfo(WorkingFolder).Name,
+ };
+
+ ///
+ /// What to show as the session's label when is blank.
+ ///
+ public string DefaultDisplayName => Kind switch
+ {
+ SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? Command : SshHost,
+ SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro)
+ ? Command
+ : (string.IsNullOrEmpty(WslWorkingFolder)
+ ? WslDistro
+ : $"{WslDistro}: {System.IO.Path.GetFileName(WslWorkingFolder.TrimEnd('/'))}"),
+ _ => System.IO.Path.GetFileName(WorkingFolder.TrimEnd('/', '\\')) ?? Command,
+ };
+
+ ///
+ /// Key used by ColorService to pick a deterministic accent color. Worktree
+ /// siblings share an accent via the repo-root override done in ;
+ /// this is the base key when no repo-root is known.
+ ///
+ public string AccentKey => Kind switch
+ {
+ SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}",
+ SessionKind.Wsl => $"wsl://{WslDistro}{WslWorkingFolder}",
+ _ => WorkingFolder,
+ };
+
+ private string BuildWslFolderShort()
+ {
+ if (string.IsNullOrWhiteSpace(WslDistro)) return "";
+ // Path.GetFileName understands both separators on Windows and returns ""
+ // for empty input, so it covers our "WslWorkingFolder might be blank" case.
+ string leaf = string.IsNullOrWhiteSpace(WslWorkingFolder)
+ ? ""
+ : System.IO.Path.GetFileName(WslWorkingFolder.TrimEnd('/'));
+ return string.IsNullOrEmpty(leaf) ? WslDistro : $"{WslDistro}: {leaf}";
+ }
}
diff --git a/src/CodeShellManager/Properties/launchSettings.json b/src/CodeShellManager/Properties/launchSettings.json
new file mode 100644
index 0000000..7b7223d
--- /dev/null
+++ b/src/CodeShellManager/Properties/launchSettings.json
@@ -0,0 +1,9 @@
+{
+ "profiles": {
+ "CodeShellManager": {
+ "commandName": "Project",
+ "commandLineArgs": "--clean",
+ "hotReloadEnabled": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CodeShellManager/Services/AlertDetector.cs b/src/CodeShellManager/Services/AlertDetector.cs
index d5705a8..8d4976f 100644
--- a/src/CodeShellManager/Services/AlertDetector.cs
+++ b/src/CodeShellManager/Services/AlertDetector.cs
@@ -38,6 +38,7 @@ public void Feed(string rawOutput)
}
_idleTimer?.Dispose();
+ // 1.5s idle: long enough for Claude's prompt redraw bursts to settle, short enough to feel responsive.
_idleTimer = new System.Threading.Timer(OnIdle, null, 1500, Timeout.Infinite);
}
diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs
index 9d50ff4..39108b5 100644
--- a/src/CodeShellManager/Services/GitService.cs
+++ b/src/CodeShellManager/Services/GitService.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Text;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace CodeShellManager.Services;
@@ -166,6 +168,13 @@ public static async Task> ListBranchesAsync(string folderP
private static async Task<(string stdout, string stderr, int exit)> RunGitFullAsync(
string workingDir, string arguments, int timeoutMs)
{
+ // WSL working folders (\\wsl$\\…) get routed through wsl.exe so git
+ // runs inside the distro. Git for Windows trips on WSL UNCs (dubious-ownership
+ // checks, .git symlink quirks) and reports "not a git repo" for valid repos.
+ var (wslDistro, linuxPath) = TryParseWslUnc(workingDir);
+ if (wslDistro != null)
+ return await RunGitInWslAsync(wslDistro, linuxPath, arguments, timeoutMs);
+
var psi = new ProcessStartInfo("git")
{
Arguments = $"-C \"{workingDir}\" {arguments}",
@@ -193,4 +202,125 @@ public static async Task> ListBranchesAsync(string folderP
string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : "";
return (stdout, stderr, process.HasExited ? process.ExitCode : -1);
}
+
+ ///
+ /// Runs wsl.exe -d <distro> -- git -C <linuxPath> <arguments>.
+ /// Translates any WSL UNC paths in to Linux form
+ /// before invocation (so things like worktree add "\\wsl$\Ubuntu\…" reach
+ /// git as a normal Linux path), and translates absolute Linux paths in stdout
+ /// back to UNC form so callers receive Windows-shaped paths.
+ ///
+ private static async Task<(string stdout, string stderr, int exit)> RunGitInWslAsync(
+ string distro, string linuxPath, string arguments, int timeoutMs)
+ {
+ string translatedArgs = TranslateUncArgsToLinux(arguments, distro);
+ // QuoteForCmd handles spaces in both the distro name (rare) and the cwd
+ // (Linux paths often have them) without disturbing the simple-name case.
+ string cwd = string.IsNullOrEmpty(linuxPath) ? "/" : linuxPath;
+ string args = $"-d {Models.ShellSession.QuoteForCmd(distro)} -- git -C {Models.ShellSession.QuoteForCmd(cwd)} {translatedArgs}";
+
+ var psi = new ProcessStartInfo("wsl.exe")
+ {
+ Arguments = args,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ using var process = Process.Start(psi);
+ if (process is null) return ("", "", -1);
+
+ var outTask = process.StandardOutput.ReadToEndAsync();
+ var errTask = process.StandardError.ReadToEndAsync();
+ var bothTask = Task.WhenAll(outTask, errTask);
+ var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs));
+ if (completed != bothTask) { try { process.Kill(); } catch { } }
+ try { await process.WaitForExitAsync(); } catch { }
+
+ string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : "";
+ string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : "";
+ stdout = TranslateLinuxPathsToUnc(stdout, distro);
+ return (stdout, stderr, process.HasExited ? process.ExitCode : -1);
+ }
+
+ ///
+ /// Detects a \\wsl$\<distro>\… or \\wsl.localhost\<distro>\…
+ /// path and splits it into (distro, linux-path). Returns (null, "") otherwise.
+ ///
+ internal static (string? distro, string linuxPath) TryParseWslUnc(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return (null, "");
+ string normalized = path.Replace('/', '\\').TrimEnd('\\');
+ string[] prefixes = { @"\\wsl$\", @"\\wsl.localhost\" };
+ foreach (var prefix in prefixes)
+ {
+ if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
+ string rest = normalized[prefix.Length..];
+ if (string.IsNullOrEmpty(rest)) return (null, "");
+ int slash = rest.IndexOf('\\');
+ string distro = slash < 0 ? rest : rest[..slash];
+ string linuxRest = slash < 0 ? "" : rest[(slash + 1)..];
+ string linuxPath = string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/');
+ return (distro, linuxPath);
+ }
+ return (null, "");
+ }
+
+ ///
+ /// Replaces WSL UNC tokens in a git arg string with their Linux equivalents.
+ /// Only translates UNCs that belong to — a UNC for a
+ /// different distro is passed through unchanged (so the caller sees the eventual
+ /// "no such directory" error rather than silently aiming at the wrong tree).
+ ///
+ internal static string TranslateUncArgsToLinux(string arguments, string distro)
+ {
+ if (string.IsNullOrEmpty(arguments)) return arguments;
+ string esc = Regex.Escape(distro);
+ string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}";
+
+ // Pass 1: quoted UNCs ("\\wsl$\\..."). The tail may contain spaces
+ // and runs until the closing quote — without this pass, the unquoted regex
+ // below would stop at the first space and produce a half-translated path.
+ arguments = Regex.Replace(arguments, $@"""({body}(?:\\[^""]*)?)""", m =>
+ {
+ var (_, linux) = TryParseWslUnc(m.Groups[1].Value);
+ return "\"" + (string.IsNullOrEmpty(linux) ? "/" : linux) + "\"";
+ }, RegexOptions.IgnoreCase);
+
+ // Pass 2: unquoted UNCs. The tail runs to whitespace; if a path needed
+ // spaces it would have been quoted and handled above.
+ arguments = Regex.Replace(arguments, $@"{body}(?:\\[^""\s]*)?", m =>
+ {
+ var (_, linux) = TryParseWslUnc(m.Value);
+ return string.IsNullOrEmpty(linux) ? "/" : linux;
+ }, RegexOptions.IgnoreCase);
+
+ return arguments;
+ }
+
+ ///
+ /// Replaces absolute Linux paths in (typically git stdout)
+ /// with \\wsl$\<distro>\… equivalents so callers see Windows-shaped
+ /// paths. Conservative — only matches tokens at start-of-line or after whitespace
+ /// to avoid mangling text that happens to contain a slash.
+ ///
+ internal static string TranslateLinuxPathsToUnc(string text, string distro)
+ {
+ if (string.IsNullOrEmpty(text)) return text;
+ // Tail used to stop at any whitespace, which mangled paths containing spaces
+ // (`/home/alice/My Projects/proj` came back as `\\wsl$\Ubuntu\home\alice\My`
+ // with the rest left as forward-slashed garbage). Our callers (rev-parse,
+ // worktree list --porcelain) always emit the path as the full remainder of
+ // the line, so widening the tail to "anything but newline / shell-meta" is
+ // safe and recovers space-containing paths correctly.
+ return Regex.Replace(text, @"(^|[\s=:])(/[^\r\n'""<>|]+)", m =>
+ {
+ string linuxPath = m.Groups[2].Value;
+ string unc = $@"\\wsl$\{distro}" + linuxPath.Replace('/', '\\');
+ return m.Groups[1].Value + unc;
+ }, RegexOptions.Multiline);
+ }
}
diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs
index 07329b9..9204516 100644
--- a/src/CodeShellManager/Services/RunInstance.cs
+++ b/src/CodeShellManager/Services/RunInstance.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
@@ -29,6 +30,8 @@ public partial class RunInstance : ObservableObject, IDisposable
public string ItemId { get; }
public string Label { get; }
public string CommandLine { get; }
+ public RunMode Mode { get; }
+ public string? PostRunUrl { get; }
[ObservableProperty] private RunState _state = RunState.Idle;
[ObservableProperty] private int? _exitCode;
@@ -38,7 +41,8 @@ public partial class RunInstance : ObservableObject, IDisposable
public TimeSpan? Duration => StartedAt is { } s && EndedAt is { } e ? e - s : null;
- private PseudoTerminal? _pty;
+ private IPseudoTerminal? _pty;
+ private readonly Func _ptyFactory;
private readonly StringBuilder _ansiStripped = new();
private readonly object _bufLock = new();
private bool _disposed;
@@ -47,15 +51,28 @@ public partial class RunInstance : ObservableObject, IDisposable
public event Action? StateChanged;
public RunInstance(RunCommandItem item)
+ : this(item, static () => new PseudoTerminal())
+ {
+ }
+
+ ///
+ /// Test seam — accepts a factory that produces an .
+ /// Production code uses the parameterless ctor which delegates to .
+ ///
+ internal RunInstance(RunCommandItem item, Func ptyFactory)
{
ItemId = item.Id;
Label = item.Label;
CommandLine = item.CommandLine;
+ Mode = item.Mode;
+ PostRunUrl = item.PostRunUrl;
+ _ptyFactory = ptyFactory;
}
///
- /// Spawns the child PTY. Builds the command line based on whether the parent
- /// is local or remote — see / .
+ /// Spawns the child PTY. Builds the command line based on the parent's
+ /// — see ,
+ /// , and .
///
public void Start(ShellSession parent)
{
@@ -69,24 +86,41 @@ public void Start(ShellSession parent)
State = RunState.Running;
StateChanged?.Invoke();
- _pty = new PseudoTerminal();
+ _pty = _ptyFactory();
_pty.DataReceived += OnPtyData;
_pty.Exited += OnPtyExited;
string command, args, workDir;
- if (parent.IsRemote)
+ switch (parent.Kind)
{
- command = "ssh";
- args = BuildSshArgs(parent, CommandLine);
- workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- }
- else
- {
- command = "cmd";
- args = BuildLocalCmd(CommandLine);
- workDir = Directory.Exists(parent.WorkingFolder)
- ? parent.WorkingFolder
- : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ case SessionKind.Ssh:
+ // SSH parents always go through bash — Mode is meaningless for remote runs.
+ command = "ssh";
+ args = BuildSshArgs(parent, CommandLine);
+ workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ break;
+ case SessionKind.Wsl:
+ // WSL parents wrap the command in `wsl.exe … -- bash -lc` —
+ // running pwsh inside WSL is out of scope so Mode is ignored here too.
+ command = "wsl.exe";
+ args = BuildWslArgs(parent, CommandLine);
+ workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ break;
+ default:
+ if (Mode == RunMode.PowerShell)
+ {
+ command = ResolvePwsh();
+ args = BuildPwshArgs(CommandLine);
+ }
+ else
+ {
+ command = "cmd";
+ args = BuildLocalCmd(CommandLine);
+ }
+ workDir = Directory.Exists(parent.WorkingFolder)
+ ? parent.WorkingFolder
+ : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ break;
}
_pty.Start(command, args, workDir, cols: 200, rows: 50, useJobObject: true);
@@ -120,6 +154,29 @@ private void OnPtyExited()
ExitCode = _pty?.ExitCode;
State = ExitCode == 0 ? RunState.ExitedOk : RunState.ExitedFailed;
StateChanged?.Invoke();
+
+ // Open post-run URL on success only. ShellExecute hands the URL to the
+ // OS default browser. We can't pop UI from the PTY-exit callback thread,
+ // so failures are logged to crash.log for diagnosability rather than silenced.
+ if (State == RunState.ExitedOk && !string.IsNullOrWhiteSpace(PostRunUrl))
+ {
+ try { Process.Start(new ProcessStartInfo(PostRunUrl) { UseShellExecute = true }); }
+ catch (Exception ex) { LogPostRunUrlFailure(PostRunUrl, ex); }
+ }
+ }
+
+ private static void LogPostRunUrlFailure(string url, Exception ex)
+ {
+ try
+ {
+ string path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "CodeShellManager", "crash.log");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.AppendAllText(path,
+ $"[{DateTime.Now:HH:mm:ss.fff}] PostRunUrl failed '{url}': {ex.Message}\n");
+ }
+ catch { /* logger failure is not actionable */ }
}
///
@@ -137,6 +194,61 @@ public string SnapshotOutput()
///
internal static string BuildLocalCmd(string commandLine) => $"/c \"{commandLine}\"";
+ ///
+ /// Returns "pwsh.exe" if PowerShell 7+ is on PATH, otherwise falls back to
+ /// the Windows-bundled "powershell.exe". ConPTY's CreateProcess resolves PATH
+ /// for us — we just pick which name to ask for.
+ ///
+ internal static string ResolvePwsh()
+ {
+ // Cheap check: try to spawn pwsh -NoLogo -Command "exit". If it returns,
+ // we trust pwsh is on PATH. Use a one-shot Process so we don't perturb
+ // the user's environment. Skip the probe if we already know.
+ if (_pwshResolved is { } cached) return cached;
+
+ try
+ {
+ using var probe = Process.Start(new ProcessStartInfo
+ {
+ FileName = "pwsh.exe",
+ Arguments = "-NoLogo -NoProfile -Command \"exit 0\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ });
+ if (probe != null)
+ {
+ // Cache pwsh only if the probe actually exited cleanly. A hung probe
+ // (WaitForExit returns false) or non-zero exit means pwsh is in a bad
+ // state; fall back to powershell.exe instead of caching a broken choice.
+ if (probe.WaitForExit(2000) && probe.ExitCode == 0)
+ {
+ _pwshResolved = "pwsh.exe";
+ return _pwshResolved;
+ }
+ try { if (!probe.HasExited) probe.Kill(entireProcessTree: true); }
+ catch { }
+ }
+ }
+ catch { /* not on PATH */ }
+
+ _pwshResolved = "powershell.exe";
+ return _pwshResolved;
+ }
+ private static string? _pwshResolved;
+
+ ///
+ /// Builds powershell args using -EncodedCommand so we don't have to worry
+ /// about quoting in the user's command line. The payload is UTF-16 LE base64,
+ /// which is what -EncodedCommand expects.
+ ///
+ internal static string BuildPwshArgs(string commandLine)
+ {
+ string b64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(commandLine));
+ return $"-NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand {b64}";
+ }
+
///
/// Builds ssh args for a remote run. Pattern:
/// -p PORT -t user@host "cd '/folder' && bash -c ''"
@@ -158,6 +270,31 @@ internal static string BuildSshArgs(ShellSession parent, string commandLine)
return sb.ToString();
}
+ ///
+ /// Builds wsl.exe args for a run executed inside the parent's WSL distro. Pattern:
+ /// -d <distro> [-u <user>] [--cd <folder>] -- bash -lc '<escaped>'
+ ///
+ internal static string BuildWslArgs(ShellSession parent, string commandLine)
+ {
+ var sb = new StringBuilder();
+ sb.Append($"-d {ShellSession.QuoteForCmd(parent.WslDistro)}");
+ if (!string.IsNullOrWhiteSpace(parent.WslUser))
+ sb.Append($" -u {ShellSession.QuoteForCmd(parent.WslUser)}");
+ if (!string.IsNullOrWhiteSpace(parent.WslWorkingFolder))
+ sb.Append($" --cd {ShellSession.QuoteForCmd(parent.WslWorkingFolder)}");
+ // Use Windows-style double quotes here, NOT POSIX single quotes: wsl.exe is
+ // launched directly by CreateProcess (no outer shell), so Windows command-line
+ // tokenization runs first and only respects "..." for grouping. Single quotes
+ // would leak through literally — `bash -lc 'cargo test'` reaches bash split at
+ // the space into the two args `'cargo` and `test'`, and bash then chokes on
+ // the unbalanced quote. ShellSession.BuildWslArgs uses this same double-quote
+ // shape; we mirror it for parity.
+ sb.Append(" -- bash -lc \"");
+ sb.Append(commandLine.Replace("\"", "\\\""));
+ sb.Append("\"");
+ return sb.ToString();
+ }
+
///
/// POSIX single-quote escape: wraps in single quotes, replacing any inner
/// single quote with '\'' so the shell still receives the literal char.
@@ -186,6 +323,9 @@ public void Dispose()
}
}
- [GeneratedRegex(@"\x1B\[[0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?\x07|\x1B[=>]|\r", RegexOptions.Compiled)]
+ // Mirrors the strip regex in OutputIndexer.AnsiPattern — keep them in sync.
+ // The `?` inside [?0-9;]* covers CSI private-mode sequences like ESC[?9001h
+ // (ConPTY's Win32 input-mode bootstrap), ESC[?1049h (alt screen), etc.
+ [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)]
private static partial Regex AnsiPattern();
}
diff --git a/src/CodeShellManager/Services/SessionRunner.cs b/src/CodeShellManager/Services/SessionRunner.cs
index 78598f8..255bc4b 100644
--- a/src/CodeShellManager/Services/SessionRunner.cs
+++ b/src/CodeShellManager/Services/SessionRunner.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using CodeShellManager.Models;
+using CodeShellManager.Terminal;
namespace CodeShellManager.Services;
@@ -14,12 +15,24 @@ public class SessionRunner : IDisposable
{
private readonly ShellSession _session;
private readonly Dictionary _instances = new();
+ private readonly Func? _ptyFactory;
/// Fires when any instance is added, replaced, or removed, or any state changes.
public event Action? InstancesChanged;
public SessionRunner(ShellSession session) { _session = session; }
+ ///
+ /// Test seam — when a factory is supplied, wires every
+ /// to it instead of letting RunInstance default
+ /// to a real .
+ ///
+ internal SessionRunner(ShellSession session, Func ptyFactory)
+ {
+ _session = session;
+ _ptyFactory = ptyFactory;
+ }
+
public IReadOnlyDictionary Instances => _instances;
public RunInstance? GetInstance(string itemId) =>
@@ -39,7 +52,9 @@ public RunInstance Run(RunCommandItem item)
_instances.Remove(item.Id);
}
- var inst = new RunInstance(item);
+ var inst = _ptyFactory is null
+ ? new RunInstance(item)
+ : new RunInstance(item, _ptyFactory);
inst.StateChanged += OnInstanceStateChanged;
inst.OutputChanged += OnInstanceOutputChanged;
_instances[item.Id] = inst;
diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs
new file mode 100644
index 0000000..884f283
--- /dev/null
+++ b/src/CodeShellManager/Services/WslDiscoveryService.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace CodeShellManager.Services;
+
+///
+/// One installed WSL distro as reported by wsl -l -v.
+///
+/// Distro name (matches the -d argument to wsl.exe).
+/// WSL version (1 or 2). 0 if the column failed to parse.
+/// True for the distro flagged with * in the listing.
+/// Reported lifecycle state, e.g. "Running", "Stopped".
+public record WslDistro(string Name, int Version, bool IsDefault, string State);
+
+///
+/// Enumerates WSL distros installed on the current Windows host. Returns an empty list
+/// when wsl.exe is missing or returns an error (e.g. no distros installed).
+///
+public static class WslDiscoveryService
+{
+ ///
+ /// Returns the currently installed distros. The result is suitable for populating
+ /// a UI picker; the default distro (if any) is marked via .
+ /// Never throws — every failure mode collapses to an empty list.
+ ///
+ public static async Task> GetDistrosAsync()
+ {
+ if (!OperatingSystem.IsWindows()) return Array.Empty();
+
+ try
+ {
+ var psi = new ProcessStartInfo("wsl.exe")
+ {
+ // -l -v is the verbose listing. --quiet is intentionally NOT used so we
+ // get the header row and the asterisk marker for the default distro.
+ Arguments = "-l -v",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ // wsl.exe writes its listings as UTF-16 LE (the same as PowerShell's
+ // default). Without this override we'd read each character interleaved
+ // with NUL bytes and the parser would see gibberish.
+ StandardOutputEncoding = Encoding.Unicode,
+ StandardErrorEncoding = Encoding.Unicode,
+ };
+
+ using var process = Process.Start(psi);
+ if (process is null) return Array.Empty();
+
+ var outTask = process.StandardOutput.ReadToEndAsync();
+ var bothTask = Task.WhenAll(outTask, process.StandardError.ReadToEndAsync());
+ var completed = await Task.WhenAny(bothTask, Task.Delay(3000));
+ if (completed != bothTask)
+ {
+ try { process.Kill(); } catch { }
+ return Array.Empty();
+ }
+ try { await process.WaitForExitAsync(); } catch { }
+ if (process.ExitCode != 0) return Array.Empty();
+
+ return Parse(outTask.Result);
+ }
+ catch (Exception)
+ {
+ // Honor the "Never throws" contract: every failure mode (wsl.exe absent,
+ // I/O hiccup, transient process error) collapses to an empty list so the
+ // dialog's Loaded handler never crashes the picker. Specific causes were
+ // previously caught individually (Win32Exception for missing wsl.exe,
+ // FileNotFoundException) but Process.Start + the read pipeline can throw
+ // a wider set than that.
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// Parses the body of wsl -l -v. Exposed for testing.
+ ///
+ internal static IReadOnlyList Parse(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return Array.Empty();
+
+ var results = new List();
+ foreach (var line in raw.Replace("\r", "").Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ // Header row: " NAME STATE VERSION".
+ // Detect by the presence of the literal "NAME" token and skip.
+ if (line.TrimStart().StartsWith("NAME", StringComparison.Ordinal)) continue;
+
+ var tokens = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+
+ bool isDefault = tokens.Length > 0 && tokens[0] == "*";
+ int firstNameIdx = isDefault ? 1 : 0;
+
+ // `wsl -l -v` always emits three columns: NAME, STATE, VERSION. NAME can
+ // contain spaces if the user `wsl --import`'d a distro with one (rare but
+ // legal), so consume from the end instead of the start: last token is
+ // VERSION, second-to-last is STATE, anything in between is the name.
+ if (tokens.Length - firstNameIdx < 3) continue;
+
+ int versionIdx = tokens.Length - 1;
+ int stateIdx = tokens.Length - 2;
+ string name = string.Join(' ', tokens, firstNameIdx, stateIdx - firstNameIdx);
+ string state = tokens[stateIdx];
+ int.TryParse(tokens[versionIdx], out int version);
+
+ results.Add(new WslDistro(name, version, isDefault, state));
+ }
+ // Stable ordering: default first, then alphabetical.
+ return results
+ .OrderByDescending(d => d.IsDefault)
+ .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ///
+ /// Resolves the home directory inside a WSL distro for the given user (or the distro's
+ /// default user when is null/empty). Cached per (distro, user) —
+ /// shells out once via wsl -d <distro> [-u <user>] -- sh -c "cd ~ && pwd"
+ /// then returns the cached value on subsequent calls. Returns null on failure
+ /// (WSL not running, command timeout, or non-zero exit).
+ ///
+ public static async Task GetDistroHomeAsync(string distro, string? user = null)
+ {
+ if (string.IsNullOrWhiteSpace(distro)) return null;
+ string normalizedUser = user?.Trim() ?? "";
+ string key = $"{distro}|{normalizedUser}";
+ lock (_homeCache)
+ {
+ if (_homeCache.TryGetValue(key, out var cached)) return cached;
+ }
+
+ try
+ {
+ // QuoteForCmd for parity with the WSL arg builders — distro and user are
+ // usually space-free but Parse now accepts space-containing names, so the
+ // launcher side must not break on the same input.
+ string args = $"-d {Models.ShellSession.QuoteForCmd(distro)}";
+ if (!string.IsNullOrEmpty(normalizedUser))
+ args += $" -u {Models.ShellSession.QuoteForCmd(normalizedUser)}";
+ args += " -- sh -c \"cd ~ && pwd\"";
+
+ var psi = new ProcessStartInfo("wsl.exe")
+ {
+ Arguments = args,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ using var process = Process.Start(psi);
+ if (process is null) return null;
+
+ // Drain BOTH stdout and stderr. If we only awaited stdout, a chatty
+ // wsl.exe error (e.g. distro stopped, transient init message) could
+ // fill the stderr pipe buffer and block the child — the stdout await
+ // would never complete and we'd silently fall through to the timeout.
+ var outTask = process.StandardOutput.ReadToEndAsync();
+ var errTask = process.StandardError.ReadToEndAsync();
+ var bothTask = Task.WhenAll(outTask, errTask);
+ var completed = await Task.WhenAny(bothTask, Task.Delay(3000));
+ if (completed != bothTask) { try { process.Kill(); } catch { } return null; }
+ try { await process.WaitForExitAsync(); } catch { }
+ if (process.ExitCode != 0) return null;
+
+ string home = outTask.Result.Trim();
+ if (string.IsNullOrEmpty(home)) return null;
+ lock (_homeCache) _homeCache[key] = home;
+ return home;
+ }
+ catch (Exception) { return null; }
+ }
+
+ private static readonly Dictionary _homeCache = new();
+
+ ///
+ /// Converts a WSL distro + Linux-style path to the Windows UNC view of that path
+ /// (\\wsl$\Ubuntu\home\alice). Used by GitService and PseudoTerminal's
+ /// working-directory argument so Windows-native tools can read the WSL filesystem.
+ /// Returns an empty string when either input is empty.
+ ///
+ public static string ToUncPath(string distro, string linuxPath)
+ {
+ if (string.IsNullOrWhiteSpace(distro)) return "";
+ if (string.IsNullOrWhiteSpace(linuxPath)) return $@"\\wsl$\{distro}";
+ string trimmed = linuxPath.TrimStart('/').Replace('/', '\\');
+ return $@"\\wsl$\{distro}\{trimmed}";
+ }
+}
diff --git a/src/CodeShellManager/Terminal/IPseudoTerminal.cs b/src/CodeShellManager/Terminal/IPseudoTerminal.cs
new file mode 100644
index 0000000..8337ee0
--- /dev/null
+++ b/src/CodeShellManager/Terminal/IPseudoTerminal.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace CodeShellManager.Terminal;
+
+///
+/// Minimum surface of consumed by
+/// . Extracted so callers can be unit-tested
+/// without spawning a real ConPTY child — production code still constructs the
+/// concrete ; tests substitute a fake.
+///
+public interface IPseudoTerminal : IDisposable
+{
+ /// Fires from the PTY read loop's thread with a decoded UTF-8 chunk.
+ event Action? DataReceived;
+
+ /// Fires once after the child process has exited and is populated.
+ event Action? Exited;
+
+ /// Exit code of the child process. Null while running.
+ int? ExitCode { get; }
+
+ /// Spawns the child process. Mirrors .
+ void Start(string command, string args, string workingDirectory,
+ int cols = 220, int rows = 50, bool useJobObject = false);
+}
diff --git a/src/CodeShellManager/Terminal/OutputIndexer.cs b/src/CodeShellManager/Terminal/OutputIndexer.cs
index 012d7c0..c94033e 100644
--- a/src/CodeShellManager/Terminal/OutputIndexer.cs
+++ b/src/CodeShellManager/Terminal/OutputIndexer.cs
@@ -81,6 +81,10 @@ public void Dispose()
catch { /* AggregateException from worker exceptions — already swallowed inside */ }
}
- [GeneratedRegex(@"\x1B\[[0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?\x07|\x1B[=>]|\r", RegexOptions.Compiled)]
+ // Mirrors the strip regex in RunInstance.AnsiPattern — keep them in sync.
+ // The `?` inside [?0-9;]* covers CSI private-mode sequences like ESC[?9001h
+ // (ConPTY's Win32 input-mode bootstrap), ESC[?1049h (alt screen), etc.
+ // OSC strings may terminate with BEL (\x07) or ST (ESC\).
+ [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)]
private static partial Regex AnsiPattern();
}
diff --git a/src/CodeShellManager/Terminal/PseudoTerminal.cs b/src/CodeShellManager/Terminal/PseudoTerminal.cs
index f5a45e6..1208e37 100644
--- a/src/CodeShellManager/Terminal/PseudoTerminal.cs
+++ b/src/CodeShellManager/Terminal/PseudoTerminal.cs
@@ -11,7 +11,7 @@ namespace CodeShellManager.Terminal;
///
/// Wraps the Windows ConPTY (Pseudo Console) API to host an interactive terminal process.
///
-public sealed class PseudoTerminal : IDisposable
+public sealed class PseudoTerminal : IPseudoTerminal
{
// ── P/Invoke ──────────────────────────────────────────────────────────────
@@ -69,6 +69,16 @@ private static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInf
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool DuplicateHandle(IntPtr hSourceProcessHandle, IntPtr hSourceHandle,
+ IntPtr hTargetProcessHandle, out IntPtr lpTargetHandle, uint dwDesiredAccess,
+ bool bInheritHandle, uint dwOptions);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentProcess();
+
+ private const uint DUPLICATE_SAME_ACCESS = 0x00000002;
+
// ── Structs ───────────────────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential)]
@@ -345,9 +355,25 @@ private async Task ReadLoopAsync()
private async Task MonitorExitAsync()
{
- await Task.Run(() => WaitForSingleObject(_hProcess, 0xFFFFFFFF));
- if (_hProcess != IntPtr.Zero && GetExitCodeProcess(_hProcess, out uint code))
- ExitCode = unchecked((int)code);
+ // Duplicate _hProcess so Dispose() can close the original without racing the wait.
+ // Closing a handle that another thread is waiting on is Win32 UB — the wait may
+ // return prematurely and we'd fire Exited before the child actually exits.
+ if (!DuplicateHandle(GetCurrentProcess(), _hProcess, GetCurrentProcess(),
+ out IntPtr waitHandle, 0, false, DUPLICATE_SAME_ACCESS))
+ {
+ Log($"DuplicateHandle failed: {Marshal.GetLastWin32Error()}");
+ return;
+ }
+ try
+ {
+ await Task.Run(() => WaitForSingleObject(waitHandle, 0xFFFFFFFF));
+ if (GetExitCodeProcess(waitHandle, out uint code))
+ ExitCode = unchecked((int)code);
+ }
+ finally
+ {
+ CloseHandle(waitHandle);
+ }
Exited?.Invoke();
}
diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs
index 6f9e050..518e8b7 100644
--- a/src/CodeShellManager/Terminal/TerminalBridge.cs
+++ b/src/CodeShellManager/Terminal/TerminalBridge.cs
@@ -24,6 +24,15 @@ public sealed class TerminalBridge : IDisposable
// so the PTY starts at the right dimensions even if resize fired before AttachPty.
private (int cols, int rows) _lastSize = (80, 24);
+ // Boot overlay — set by MainWindow before InitializeAsync; posted as setBootState after
+ // navigation completes, and hidden via bootDone on the first PTY byte (see OnPtyData).
+ // Fallback hides the overlay after BootDoneFallbackMs so silent sessions (e.g. a child
+ // that prints nothing, an SSH connect that's mid-handshake) aren't locked out.
+ private const int BootDoneFallbackMs = 8000;
+ private string? _bootLabel;
+ private string? _bootAccentHex;
+ private int _bootDoneFlag; // 0 = overlay still visible, 1 = bootDone already posted
+
// Output that arrived before the page finished loading is buffered here
private readonly System.Text.StringBuilder _outputBuffer = new();
@@ -73,11 +82,33 @@ private void Trace(string msg)
catch { }
}
+ // Posts a one-shot bootDone message to the WebView2. Safe to call from any thread.
+ private void PostBootDoneIfNeeded()
+ {
+ if (System.Threading.Interlocked.CompareExchange(ref _bootDoneFlag, 1, 0) != 0) return;
+ WpfApplication.Current?.Dispatcher.BeginInvoke(() =>
+ {
+ try { _webView.CoreWebView2?.PostWebMessageAsString("{\"type\":\"bootDone\"}"); }
+ catch { }
+ });
+ }
+
public TerminalBridge(WebView2 webView)
{
_webView = webView;
}
+ ///
+ /// Sets the boot-overlay label and accent color. Must be called before
+ /// — the bridge posts a setBootState message to the
+ /// page as soon as navigation completes.
+ ///
+ public void SetBootContext(string label, string accentHex)
+ {
+ _bootLabel = label;
+ _bootAccentHex = accentHex;
+ }
+
///
/// Initializes WebView2, navigates to terminal.html and AWAITS full page load
/// before returning. This ensures PTY output is never dropped.
@@ -96,6 +127,11 @@ public async Task InitializeAsync(string htmlPath)
await _webView.EnsureCoreWebView2Async(env);
Log("EnsureCoreWebView2Async done");
+ // Match the boot overlay background so the WebView2 init flicker (the gap between
+ // the control becoming visible and terminal.html rendering) is invisible.
+ try { _webView.DefaultBackgroundColor = System.Drawing.Color.FromArgb(0x1e, 0x1e, 0x2e); }
+ catch { }
+
var settings = _webView.CoreWebView2.Settings;
settings.AreDevToolsEnabled = true; // enable for debugging
settings.AreDefaultContextMenusEnabled = false;
@@ -137,6 +173,26 @@ void NavCompleted(object? s, CoreWebView2NavigationCompletedEventArgs e)
Log($"NavigationCompleted: success={e.IsSuccess} httpStatus={e.HttpStatusCode} webErrorStatus={e.WebErrorStatus}");
_ready = true;
+ // Apply boot-overlay state if MainWindow called SetBootContext before init.
+ if (_bootLabel != null && _bootAccentHex != null)
+ {
+ var bootJson = JsonSerializer.Serialize(new
+ {
+ type = "setBootState",
+ label = _bootLabel,
+ accentHex = _bootAccentHex
+ });
+ try { _webView.CoreWebView2?.PostWebMessageAsString(bootJson); }
+ catch { }
+ }
+
+ // Silent-session fallback: if the child writes nothing (or exits without
+ // any output) the overlay would otherwise block the terminal indefinitely.
+ // PostBootDoneIfNeeded is idempotent via Interlocked, so this is a no-op
+ // when the first PTY byte beat the timer.
+ _ = Task.Delay(BootDoneFallbackMs).ContinueWith(
+ _ => PostBootDoneIfNeeded(), TaskScheduler.Default);
+
// Flush any PTY output that arrived during page load
string buffered;
lock (_outputBuffer)
@@ -188,6 +244,7 @@ private void OnPtyData(string rawData)
}
RawOutputReceived?.Invoke(rawData);
+ PostBootDoneIfNeeded();
if (!_ready)
{
@@ -403,6 +460,7 @@ public void FocusTerminal()
public void Dispose()
{
+ PostBootDoneIfNeeded();
if (_pty != null) _pty.DataReceived -= OnPtyData;
if (_webView.CoreWebView2 != null)
{
diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs
index e1c8507..3802d43 100644
--- a/src/CodeShellManager/ViewModels/MainViewModel.cs
+++ b/src/CodeShellManager/ViewModels/MainViewModel.cs
@@ -10,7 +10,7 @@
namespace CodeShellManager.ViewModels;
-public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourColumn, SixColumn, SixByTwo, SixByThree }
+public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourColumn, SixColumn, SixByTwo, SixByThree, ThreeByThree }
/// Sentinel value meaning "show only sessions with no group".
public static class GroupFilter
@@ -28,6 +28,12 @@ public partial class MainViewModel : ObservableObject
public ObservableCollection Sessions { get; } = [];
+ /// Cap on the recently-closed ring buffer. Older entries fall off the end.
+ public const int MaxRecentlyClosed = 10;
+
+ /// Read-only view of the recently-closed ring buffer (newest first).
+ public IReadOnlyList RecentlyClosed => _appState.RecentlyClosed;
+
[ObservableProperty] private SessionViewModel? _activeSession;
[ObservableProperty] private LayoutMode _layout = LayoutMode.Single;
[ObservableProperty] private bool _showSearch;
@@ -305,6 +311,10 @@ public void RegisterSession(SessionViewModel vm)
private void OnSessionCloseRequested(SessionViewModel vm)
{
vm.CloseRequested -= OnSessionCloseRequested;
+ // Snapshot the underlying ShellSession into the ring buffer BEFORE disposing —
+ // Dispose() doesn't actually null the model, but capturing here keeps the
+ // ordering deterministic (push → drop → save) regardless of future changes.
+ PushRecentlyClosed(vm.Session);
Sessions.Remove(vm);
if (SelectedSessionIds.Remove(vm.Id))
SelectionChanged?.Invoke();
@@ -318,6 +328,51 @@ private void OnSessionCloseRequested(SessionViewModel vm)
_ = SaveStateAsync();
}
+ ///
+ /// Pushes a snapshot of the session onto the recently-closed ring buffer.
+ /// No-op in --clean mode (mirrors semantics —
+ /// debug runs must not pollute the persisted recent list).
+ ///
+ public void PushRecentlyClosed(ShellSession session)
+ {
+ if (App.CleanStart) return;
+ _appState.RecentlyClosed.Insert(0, RecentlyClosedEntry.FromSession(session));
+ while (_appState.RecentlyClosed.Count > MaxRecentlyClosed)
+ _appState.RecentlyClosed.RemoveAt(_appState.RecentlyClosed.Count - 1);
+ }
+
+ /// Pops and returns the most-recently-closed entry, or null if the ring is empty.
+ public RecentlyClosedEntry? PopMostRecentlyClosed()
+ {
+ if (_appState.RecentlyClosed.Count == 0) return null;
+ var e = _appState.RecentlyClosed[0];
+ _appState.RecentlyClosed.RemoveAt(0);
+ _ = SaveStateAsync();
+ return e;
+ }
+
+ ///
+ /// Returns the most-recently-closed entry without removing it. Use when the caller
+ /// wants to attempt a reopen and remove the entry only if it succeeds.
+ ///
+ public RecentlyClosedEntry? PeekMostRecentlyClosed()
+ => _appState.RecentlyClosed.Count == 0 ? null : _appState.RecentlyClosed[0];
+
+ /// Removes a specific entry from the ring (e.g. after the user reopens it from the dialog).
+ public void RemoveRecentlyClosed(RecentlyClosedEntry entry)
+ {
+ if (_appState.RecentlyClosed.Remove(entry))
+ _ = SaveStateAsync();
+ }
+
+ /// Empties the recently-closed ring. Used by --clean mode for full isolation.
+ public void ClearRecentlyClosed()
+ {
+ if (_appState.RecentlyClosed.Count == 0) return;
+ _appState.RecentlyClosed.Clear();
+ _ = SaveStateAsync();
+ }
+
[RelayCommand]
private void SetLayout(LayoutMode mode)
{
diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs
index abd4c29..d04f8e2 100644
--- a/src/CodeShellManager/ViewModels/SessionViewModel.cs
+++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs
@@ -41,31 +41,20 @@ public partial class SessionViewModel : ObservableObject, IDisposable
public string AccentColor => Session.ColorOverride
?? ColorService.GetHexColor(
- Session.IsRemote
- ? (string.IsNullOrWhiteSpace(Session.SshUser)
- ? Session.SshHost
- : $"{Session.SshUser}@{Session.SshHost}")
- // Key on RepoRoot when known so worktree siblings share a color;
- // fall back to WorkingFolder for non-git sessions.
- : (string.IsNullOrEmpty(RepoRoot) ? Session.WorkingFolder : RepoRoot));
+ // SSH never gets a RepoRoot override (no local filesystem); for Local + WSL
+ // prefer RepoRoot so worktree siblings share a color, falling back to
+ // the kind-specific accent key.
+ Session.Kind == SessionKind.Ssh
+ ? Session.AccentKey
+ : (string.IsNullOrEmpty(RepoRoot) ? Session.AccentKey : RepoRoot));
partial void OnRepoRootChanged(string? value) => OnPropertyChanged(nameof(AccentColor));
public string DisplayName => string.IsNullOrWhiteSpace(Session.Name)
- ? (Session.IsRemote
- ? (string.IsNullOrWhiteSpace(Session.SshHost) ? Session.Command : Session.SshHost)
- : System.IO.Path.GetFileName(Session.WorkingFolder.TrimEnd('/', '\\')) ?? Session.Command)
+ ? Session.DefaultDisplayName
: Session.Name;
- public string FolderShort
- {
- get
- {
- if (string.IsNullOrEmpty(Session.WorkingFolder)) return "";
- var di = new System.IO.DirectoryInfo(Session.WorkingFolder);
- return di.Name;
- }
- }
+ public string FolderShort => Session.FolderShort;
public event Action? CloseRequested;
@@ -81,7 +70,11 @@ public SessionViewModel(ShellSession session)
public async Task RefreshGitInfoAsync()
{
- if (Session.IsRemote) return;
+ // SSH sessions have no local working folder to inspect. WSL sessions store
+ // their WorkingFolder as a `\\wsl$\\...` UNC; GitService detects that
+ // and dispatches to `wsl.exe -- git -C ` internally (Git for
+ // Windows itself trips on those UNCs — dubious-ownership / .git symlinks).
+ if (Session.Kind == SessionKind.Ssh) return;
var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder);
GitBranch = branch;
GitIsDirty = isDirty;
diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml
index d285b70..f35d6bd 100644
--- a/src/CodeShellManager/Views/NewSessionDialog.xaml
+++ b/src/CodeShellManager/Views/NewSessionDialog.xaml
@@ -1,7 +1,7 @@
@@ -157,8 +157,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -218,6 +233,51 @@
ToolTip="e.g. /home/alice/project — leave blank for home directory"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
index ad63e6d..993569a 100644
--- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
+++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs
@@ -31,6 +31,12 @@ public partial class NewSessionDialog : Window
public string SshUser { get; private set; } = "";
public string SshRemoteFolder { get; private set; } = "";
+ // WSL session output
+ public bool IsWsl { get; private set; } = false;
+ public string WslDistro { get; private set; } = "";
+ public string WslUser { get; private set; } = "";
+ public string WslWorkingFolder { get; private set; } = "";
+
// Profile-driven appearance overrides (null when no profile picked)
public string? ProfileFontFamily { get; private set; }
public int? ProfileFontSize { get; private set; }
@@ -46,10 +52,30 @@ public partial class NewSessionDialog : Window
/// Paths of sibling worktrees the user opted to also launch sessions for.
public IReadOnlyList AdditionalWorktreePaths { get; private set; } = Array.Empty();
+ ///
+ /// When non-null, the user picked an entry from the "Recently closed" list instead of
+ /// filling in the form. The caller (OpenNewSessionDialogCore) should reopen this
+ /// session via ReopenClosedSessionAsync and ignore the rest of the form fields.
+ ///
+ public RecentlyClosedEntry? SelectedRecentlyClosed { get; private set; }
+
private readonly IReadOnlyList _profiles;
private readonly System.Windows.Threading.DispatcherTimer _worktreeDebounce;
private System.Threading.CancellationTokenSource? _worktreeProbeCts;
private string? _lastProbedFolder;
+ ///
+ /// What we last auto-filled into . AutoFillName uses this
+ /// to tell "the user hasn't typed anything custom" from "the user has". When
+ /// the box equals this value (or is empty), we're free to overwrite it when
+ /// the source context (folder / distro / host) changes. Anything else means
+ /// the user has edited it and we must not stomp.
+ ///
+ private string _lastAutoFilledName = "";
+ ///
+ /// Distro name we want PopulateWslDistrosAsync to pre-select once the combo
+ /// finishes loading. Empty = use the default (first / system default distro).
+ ///
+ private readonly string _preselectWslDistro = "";
public NewSessionDialog(
string defaultFolder = "",
@@ -57,11 +83,14 @@ public NewSessionDialog(
IReadOnlyList? profiles = null,
string? defaultCommand = null,
string? defaultArgs = null,
- string? defaultName = null)
+ string? defaultName = null,
+ IReadOnlyList? recentlyClosed = null,
+ ShellSession? defaultSourceSession = null)
{
InitializeComponent();
FolderBox.Text = defaultFolder;
_profiles = profiles ?? Array.Empty();
+ _preselectWslDistro = defaultSourceSession?.IsWsl == true ? defaultSourceSession.WslDistro : "";
var customItem = CommandCombo.Items[0];
CommandCombo.Items.Clear();
@@ -99,6 +128,8 @@ public NewSessionDialog(
ProfileCombo.SelectedIndex = 0;
}
+ PopulateRecentlyClosed(recentlyClosed);
+
_worktreeDebounce = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(700)
@@ -111,17 +142,61 @@ public NewSessionDialog(
FolderBox.TextChanged += (_, _) => { AutoFillName(); ScheduleWorktreeProbe(); };
SshHostBox.TextChanged += (_, _) => AutoFillName();
+ WslDistroCombo.SelectionChanged += (_, _) => AutoFillName();
+ WslWorkingFolderBox.TextChanged += (_, _) => AutoFillName();
+
+ // Inherit WSL parent: when a user right-clicks a WSL session and picks
+ // "New session here", default the new dialog to WSL mode with the same
+ // distro/user/folder pre-filled. The combo selection happens later in
+ // PopulateWslDistrosAsync (it's async-populated on Loaded).
+ if (defaultSourceSession?.IsWsl == true)
+ {
+ WslRadio.IsChecked = true;
+ WslUserBox.Text = defaultSourceSession.WslUser ?? "";
+ WslWorkingFolderBox.Text = defaultSourceSession.WslWorkingFolder ?? "";
+ }
Loaded += async (_, _) =>
{
- if (!IsRemoteMode && !string.IsNullOrWhiteSpace(FolderBox.Text))
+ if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text))
await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim());
+ await PopulateWslDistrosAsync();
};
}
+ ///
+ /// Fills WslDistroCombo from .
+ /// On hosts without WSL installed we leave the combo empty and surface a one-line hint
+ /// so the WSL radio doesn't appear broken.
+ ///
+ private async System.Threading.Tasks.Task PopulateWslDistrosAsync()
+ {
+ var distros = await WslDiscoveryService.GetDistrosAsync();
+ WslDistroCombo.Items.Clear();
+ if (distros.Count == 0)
+ {
+ WslHelpText.Text = "No WSL distros found. Install WSL from the Microsoft Store, then re-open this dialog.";
+ return;
+ }
+ ComboBoxItem? preselectMatch = null;
+ foreach (var d in distros)
+ {
+ string label = d.IsDefault ? $"{d.Name} (default, v{d.Version})" : $"{d.Name} (v{d.Version})";
+ var item = new ComboBoxItem { Content = label, Tag = d.Name };
+ WslDistroCombo.Items.Add(item);
+ if (!string.IsNullOrEmpty(_preselectWslDistro)
+ && string.Equals(d.Name, _preselectWslDistro, StringComparison.OrdinalIgnoreCase))
+ {
+ preselectMatch = item;
+ }
+ }
+ WslDistroCombo.SelectedItem = preselectMatch ?? WslDistroCombo.Items[0];
+ WslHelpText.Text = "";
+ }
+
private void ScheduleWorktreeProbe()
{
- if (IsRemoteMode)
+ if (!IsLocalMode)
{
WorktreesPanel.Visibility = Visibility.Collapsed;
return;
@@ -188,44 +263,67 @@ private async System.Threading.Tasks.Task ProbeSiblingWorktreesAsync(string fold
}
private bool IsRemoteMode => RemoteRadio?.IsChecked == true;
+ private bool IsWslMode => WslRadio?.IsChecked == true;
+ private bool IsLocalMode => !IsRemoteMode && !IsWslMode;
private void AutoFillName()
{
- if (!string.IsNullOrWhiteSpace(NameBox.Text)) return;
+ // Allow overwrite when the box is empty OR still holds our last auto-fill.
+ // Anything else means the user typed something — leave it alone.
+ if (!string.IsNullOrWhiteSpace(NameBox.Text) && NameBox.Text != _lastAutoFilledName)
+ return;
+ string suggested = "";
if (IsRemoteMode)
{
var raw = SshHostBox.Text.Trim();
if (!string.IsNullOrWhiteSpace(raw))
{
- try { NameBox.Text = raw.Split(':')[0]; }
+ try { suggested = raw.Split(':')[0]; }
catch { }
}
}
+ else if (IsWslMode)
+ {
+ string distro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? "";
+ string folder = WslWorkingFolderBox.Text.Trim();
+ string leaf = string.IsNullOrEmpty(folder)
+ ? ""
+ : Path.GetFileName(folder.TrimEnd('/'));
+ suggested = string.IsNullOrEmpty(leaf)
+ ? distro
+ : (string.IsNullOrEmpty(distro) ? leaf : $"{distro}: {leaf}");
+ }
else
{
if (!string.IsNullOrWhiteSpace(FolderBox.Text))
{
- try { NameBox.Text = Path.GetFileName(FolderBox.Text.TrimEnd('/', '\\')); }
+ try { suggested = Path.GetFileName(FolderBox.Text.TrimEnd('/', '\\')) ?? ""; }
catch { }
}
}
+
+ NameBox.Text = suggested;
+ _lastAutoFilledName = suggested;
}
private void SessionType_Changed(object sender, RoutedEventArgs e)
{
if (LocalPanel == null) return;
- LocalPanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible;
+ LocalPanel.Visibility = IsLocalMode ? Visibility.Visible : Visibility.Collapsed;
SshPanel.Visibility = IsRemoteMode ? Visibility.Visible : Visibility.Collapsed;
+ WslPanel.Visibility = IsWslMode ? Visibility.Visible : Visibility.Collapsed;
// Profile combobox is local-only
if (ProfilePanel != null && _profiles.Count > 0)
- ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible;
+ ProfilePanel.Visibility = IsLocalMode ? Visibility.Visible : Visibility.Collapsed;
if (WorktreesPanel != null)
{
WorktreesPanel.Visibility = Visibility.Collapsed;
_lastProbedFolder = null;
}
- CommandLabel.Text = IsRemoteMode ? "Remote Shell" : "Command";
+ CommandLabel.Text = IsRemoteMode ? "Remote Shell"
+ : IsWslMode ? "Shell (inside WSL)"
+ : "Command";
NameBox.Text = "";
AutoFillName();
}
@@ -245,6 +343,99 @@ private void BrowseFolder_Click(object sender, RoutedEventArgs e)
}
}
+ ///
+ /// Pops a folder picker rooted at the WSL filesystem (\\wsl$\). When the
+ /// user picks a folder under one of the distros, both the distro combo and the
+ /// Linux working-folder box update to match — so they can also switch distros
+ /// by drilling into a different one in the dialog.
+ ///
+ private async void BrowseWslFolder_Click(object sender, RoutedEventArgs e)
+ {
+ string selectedDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? "";
+ string seed = await ComputeWslBrowseSeedAsync(selectedDistro, WslUserBox.Text.Trim());
+
+ // Only InitialDirectory is set: it navigates the dialog to the seed but
+ // leaves the bottom "Folder:" textbox empty (the user is about to pick anyway).
+ // Setting SelectedPath as well shoves the raw UNC into that textbox, which the
+ // shell renders as a truncated, slash-flipped mess (e.g. "bu/home/bitblade") —
+ // worse than empty.
+ using var dialog = new System.Windows.Forms.FolderBrowserDialog
+ {
+ Description = "Select Linux working folder (inside WSL)",
+ UseDescriptionForTitle = true,
+ InitialDirectory = seed,
+ };
+ if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
+
+ var (distro, linuxPath) = ParseWslUncPath(dialog.SelectedPath);
+ if (string.IsNullOrEmpty(distro))
+ {
+ // User navigated out of the WSL share entirely (e.g. into C:\…). Putting
+ // a Windows path into the Linux-folder box would just make `wsl --cd`
+ // fail later — so refuse the selection and tell them why.
+ System.Windows.MessageBox.Show(
+ $"'{dialog.SelectedPath}' is not inside a WSL distro.\n\n" +
+ "Please pick a folder under one of the distros shown in the left pane (Linux → Ubuntu, etc.).",
+ "Not a WSL folder", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ else
+ {
+ // If they drilled into a different distro than the combo had, switch the combo too.
+ if (!string.Equals(distro, selectedDistro, StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var item in WslDistroCombo.Items.OfType())
+ {
+ if (string.Equals(item.Tag as string, distro, StringComparison.OrdinalIgnoreCase))
+ {
+ WslDistroCombo.SelectedItem = item;
+ break;
+ }
+ }
+ }
+ WslWorkingFolderBox.Text = linuxPath;
+ }
+ AutoFillName();
+ }
+
+ ///
+ /// Seed path for the WSL folder picker. Prefers the user's home directory inside
+ /// the distro (resolved via cd ~ && pwd) so picking lands somewhere
+ /// useful; falls back to the distro root when WSL isn't reachable, and to
+ /// \\wsl$ when no distro is selected yet.
+ ///
+ private async System.Threading.Tasks.Task ComputeWslBrowseSeedAsync(string distro, string user)
+ {
+ if (string.IsNullOrEmpty(distro)) return @"\\wsl$";
+ string? home = await WslDiscoveryService.GetDistroHomeAsync(distro, user);
+ if (string.IsNullOrEmpty(home)) return $@"\\wsl$\{distro}";
+ return WslDiscoveryService.ToUncPath(distro, home);
+ }
+
+ ///
+ /// Splits a WSL UNC path (\\wsl$\Ubuntu\home\alice or the
+ /// \\wsl.localhost\ variant) into (distro, linux-path). Returns empty
+ /// strings when the input isn't a recognizable WSL UNC.
+ ///
+ internal static (string distro, string linuxPath) ParseWslUncPath(string unc)
+ {
+ if (string.IsNullOrWhiteSpace(unc)) return ("", "");
+ string normalized = unc.Replace('/', '\\').TrimEnd('\\');
+ string[] prefixes = { @"\\wsl$\", @"\\wsl.localhost\" };
+ foreach (var prefix in prefixes)
+ {
+ if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
+ string rest = normalized[prefix.Length..];
+ if (string.IsNullOrEmpty(rest)) return ("", "");
+ int slash = rest.IndexOf('\\');
+ string distro = slash < 0 ? rest : rest[..slash];
+ string linuxRest = slash < 0 ? "" : rest[(slash + 1)..];
+ string linuxPath = string.IsNullOrEmpty(linuxRest) ? "" : "/" + linuxRest.Replace('\\', '/');
+ return (distro, linuxPath);
+ }
+ return ("", "");
+ }
+
private void CommandCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CustomArgsPanel == null) return;
@@ -308,12 +499,13 @@ private void ProfileCombo_SelectionChanged(object sender, SelectionChangedEventA
ProfileColorSchemeJson = profile.ColorSchemeJson;
}
- private void Start_Click(object sender, RoutedEventArgs e)
+ private async void Start_Click(object sender, RoutedEventArgs e)
{
IsRemote = IsRemoteMode;
+ IsWsl = IsWslMode;
SessionName = NameBox.Text.Trim();
- if (!IsRemoteMode && WorktreesPanel.Visibility == Visibility.Visible)
+ if (IsLocalMode && WorktreesPanel.Visibility == Visibility.Visible)
{
AdditionalWorktreePaths = WorktreesList.Children.OfType()
.Where(c => c.IsChecked == true)
@@ -323,6 +515,46 @@ private void Start_Click(object sender, RoutedEventArgs e)
.ToList();
}
+ if (IsWsl)
+ {
+ WslDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? "";
+ if (string.IsNullOrWhiteSpace(WslDistro))
+ {
+ System.Windows.MessageBox.Show(
+ "Please select a WSL distro.",
+ "Distro required", MessageBoxButton.OK, MessageBoxImage.Warning);
+ WslDistroCombo.Focus();
+ return;
+ }
+
+ WslUser = WslUserBox.Text.Trim();
+ WslWorkingFolder = WslWorkingFolderBox.Text.Trim();
+
+ // If the user left the Linux folder blank, resolve $HOME eagerly so the
+ // session's WorkingFolder UNC and its Linux path stay in sync. Otherwise
+ // git status runs against the distro root (\\wsl$\ → "/") while
+ // the shell actually starts in $HOME — and the sidebar branch info goes
+ // missing for repos under home. Best-effort: silent fallback to blank
+ // (the existing "land in $HOME, no git info" behavior) when WSL is
+ // unreachable.
+ if (string.IsNullOrEmpty(WslWorkingFolder))
+ {
+ string? home = await WslDiscoveryService.GetDistroHomeAsync(WslDistro, WslUser);
+ if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home;
+ }
+
+ var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash";
+ string raw = selectedTag == "custom" ? CustomArgsBox.Text.Trim() : selectedTag;
+ var (exe, args) = CommandLineSplitter.Split(raw);
+ SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe;
+ SelectedArgs = args;
+
+ SelectedFolder = "";
+ DialogResult = true;
+ Close();
+ return;
+ }
+
if (IsRemote)
{
if (string.IsNullOrWhiteSpace(SshHostBox.Text))
@@ -396,4 +628,57 @@ private void Cancel_Click(object sender, RoutedEventArgs e)
DialogResult = false;
Close();
}
+
+ private void PopulateRecentlyClosed(IReadOnlyList? entries)
+ {
+ if (entries == null || entries.Count == 0)
+ {
+ RecentlyClosedPanel.Visibility = Visibility.Collapsed;
+ return;
+ }
+ RecentlyClosedPanel.Visibility = Visibility.Visible;
+ var fg = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0xcd, 0xd6, 0xf4));
+ var sub = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x6c, 0x70, 0x86));
+ foreach (var entry in entries)
+ {
+ var stack = new StackPanel { Orientation = System.Windows.Controls.Orientation.Vertical };
+ stack.Children.Add(new TextBlock
+ {
+ Text = string.IsNullOrWhiteSpace(entry.Name) ? "(unnamed)" : entry.Name,
+ Foreground = fg, FontSize = 13,
+ });
+ stack.Children.Add(new TextBlock
+ {
+ Text = entry.Subtitle,
+ Foreground = sub, FontSize = 11,
+ TextTrimming = System.Windows.TextTrimming.CharacterEllipsis,
+ });
+ var btn = new System.Windows.Controls.Button
+ {
+ Content = stack,
+ Tag = entry,
+ Background = System.Windows.Media.Brushes.Transparent,
+ BorderBrush = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x31, 0x32, 0x44)),
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ Padding = new Thickness(6, 5, 6, 5),
+ HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left,
+ Cursor = System.Windows.Input.Cursors.Hand,
+ };
+ btn.Click += RecentlyClosed_Click;
+ RecentlyClosedList.Children.Add(btn);
+ }
+ }
+
+ private void RecentlyClosed_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement fe && fe.Tag is RecentlyClosedEntry entry)
+ {
+ SelectedRecentlyClosed = entry;
+ DialogResult = true;
+ Close();
+ }
+ }
}
diff --git a/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml b/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml
index 8d5dccf..2f89971 100644
--- a/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml
+++ b/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml
@@ -2,8 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Run commands"
- Width="720" Height="500"
- MinWidth="560" MinHeight="360"
+ Width="900" Height="520"
+ MinWidth="720" MinHeight="380"
WindowStartupLocation="CenterOwner"
Background="#1e1e2e"
Foreground="#cdd6f4"
@@ -39,8 +39,10 @@
-
+
+
+
-
+
-
+
+
@@ -64,8 +72,10 @@
-
+
+
+
-
+
+
+
+
-
+
+
diff --git a/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml.cs b/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml.cs
index b9624b1..75d9063 100644
--- a/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml.cs
+++ b/src/CodeShellManager/Views/SessionRunCommandsDialog.xaml.cs
@@ -26,6 +26,8 @@ public SessionRunCommandsDialog(string sessionName, IReadOnlyList _label;
@@ -124,6 +132,16 @@ public bool IsDefault
get => _isDefault;
set { _isDefault = value; OnChanged(nameof(IsDefault)); }
}
+ public int ModeIndex
+ {
+ get => _modeIndex;
+ set { _modeIndex = value; OnChanged(nameof(ModeIndex)); }
+ }
+ public string PostRunUrl
+ {
+ get => _postRunUrl;
+ set { _postRunUrl = value; OnChanged(nameof(PostRunUrl)); }
+ }
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
private void OnChanged(string n) => PropertyChanged?.Invoke(this,
diff --git a/tests/CodeShellManager.Tests/AlertDetectorTests.cs b/tests/CodeShellManager.Tests/AlertDetectorTests.cs
new file mode 100644
index 0000000..e094039
--- /dev/null
+++ b/tests/CodeShellManager.Tests/AlertDetectorTests.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Threading;
+using CodeShellManager.Models;
+using CodeShellManager.Services;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Tests for .
+///
+/// AlertDetector's only public surface for triggering an alert is ,
+/// which starts a hardcoded 1500ms . There is no clock-injection
+/// hook, and the regexes are private statics — so to assert on the regex behavior we have to let
+/// the real timer fire.
+///
+/// To stay deterministic we subscribe to with a
+/// and wait with a generous timeout. The wait completes
+/// as soon as the event fires (typically ~1.5s after Feed) — it is NOT a blind Thread.Sleep.
+/// A short negative-case timeout (200ms past the idle threshold) is used when asserting that
+/// no alert was raised.
+///
+/// IMPORTANT: every test disposes the detector BEFORE the signal goes out of scope. Otherwise
+/// the in-flight Timer callback can race with the ManualResetEventSlim finalizer and throw
+/// ObjectDisposedException on a thread-pool thread, which crashes the test host.
+///
+public class AlertDetectorTests
+{
+ // Generous upper bound; the event normally fires ~1500ms after Feed. The ManualResetEventSlim
+ // returns the instant the event fires, so a higher cap doesn't slow passing tests.
+ private const int WaitForAlertMs = 5000;
+
+ // Used to assert that *no* alert was raised. Anything > 1500ms is enough.
+ private const int WaitForNoAlertMs = 1800;
+
+ // ---- Tool approval detection -------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Do you want to run this command?")]
+ [InlineData("Allow Claude to edit this file?")]
+ [InlineData("Approve this tool use?")]
+ [InlineData("Bash command: rm -rf /tmp/foo")]
+ [InlineData("Continue?")]
+ [InlineData("Proceed?")]
+ [InlineData("tool_use requested")]
+ [InlineData("Grant permission to read")]
+ public void Feed_ClaudeApprovalPhrasing_RaisesToolApprovalAlert(string line)
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ detector.Feed(line + "\n");
+
+ Assert.True(signal.Wait(WaitForAlertMs), $"AlertRaised did not fire for: {line}");
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.ToolApproval, captured!.Type);
+ Assert.Equal("s1", captured.SessionId);
+ Assert.Equal("session", captured.SessionName);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- Input-required detection ------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Delete file [y/N]")]
+ [InlineData("Continue [Y/n]")]
+ [InlineData("Proceed [yes/no]")]
+ [InlineData("Are you sure (y/n)")]
+ [InlineData("Confirm (yes/no)")]
+ [InlineData("❯")] // Claude's "❯" prompt alone (U+276F)
+ [InlineData("something ❯")] // ❯ at end of line
+ [InlineData("Pick an option ? ›")] // "? ›" pattern (U+203A)
+ [InlineData("What now?")] // trailing "?"
+ public void Feed_InputRequiredPhrasing_RaisesInputRequiredAlert(string line)
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ detector.Feed(line + "\n");
+
+ Assert.True(signal.Wait(WaitForAlertMs), $"AlertRaised did not fire for: {line}");
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.InputRequired, captured!.Type);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Feed_TrailingGreaterThanPrompt_RaisesInputRequiredAlert()
+ {
+ // The s_prompt regex matches ">" at end of line.
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ detector.Feed("user@host:~$ >\n");
+
+ Assert.True(signal.Wait(WaitForAlertMs));
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.InputRequired, captured!.Type);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- Negative cases ----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("just some regular log output")]
+ [InlineData("Compilation succeeded.")]
+ [InlineData("12 files indexed")]
+ public void Feed_NonMatchingLine_DoesNotRaiseAlert(string line)
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ detector.AlertRaised += _ => signal.Set();
+
+ try
+ {
+ detector.Feed(line + "\n");
+
+ Assert.False(signal.Wait(WaitForNoAlertMs),
+ $"AlertRaised should NOT have fired for: {line}");
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Feed_WhitespaceOnly_DoesNotRaiseAlert()
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ detector.AlertRaised += _ => signal.Set();
+
+ try
+ {
+ detector.Feed(" \n\t\n");
+
+ Assert.False(signal.Wait(WaitForNoAlertMs));
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- ANSI stripping ----------------------------------------------------------------------
+
+ [Fact]
+ public void Feed_LineWrappedInAnsiEscapes_StillFiresAlert()
+ {
+ // Real Claude output is colorized — verify the detector strips ANSI before regex match.
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ const string esc = "\x1B";
+ string ansiWrapped = $"{esc}[1;33mDo you want to proceed?{esc}[0m\n";
+
+ detector.Feed(ansiWrapped);
+
+ Assert.True(signal.Wait(WaitForAlertMs));
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.ToolApproval, captured!.Type);
+ // The captured message should contain the human-readable text from inside the ANSI wrapping.
+ Assert.Contains("Do you want to proceed?", captured.Message);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Feed_PromptWrappedInAnsiEscapes_StillFiresInputRequired()
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ const string esc = "\x1B";
+ string ansiWrapped = $"{esc}[36m❯{esc}[0m\n";
+
+ detector.Feed(ansiWrapped);
+
+ Assert.True(signal.Wait(WaitForAlertMs));
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.InputRequired, captured!.Type);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- NotifyUserInteracted ----------------------------------------------------------------
+
+ [Fact]
+ public void NotifyUserInteracted_BeforeIdleTimer_CancelsPendingAlert()
+ {
+ // Feed a matching line, but interact before the 1500ms idle window expires.
+ // The pending timer should be cancelled, so no AlertRaised should ever fire.
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ detector.AlertRaised += _ => signal.Set();
+
+ try
+ {
+ detector.Feed("Do you want to continue?\n");
+ // Cancel immediately — no sleep, no race against the 1500ms timer.
+ detector.NotifyUserInteracted();
+
+ Assert.False(signal.Wait(WaitForNoAlertMs),
+ "NotifyUserInteracted should have cancelled the pending idle timer.");
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void NotifyUserInteracted_RaisesAlertCleared()
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ string? clearedSessionId = null;
+ detector.AlertCleared += id => { clearedSessionId = id; signal.Set(); };
+
+ try
+ {
+ detector.NotifyUserInteracted();
+
+ Assert.True(signal.Wait(1000));
+ Assert.Equal("s1", clearedSessionId);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void NotifyUserInteracted_ResetsFiredFlag_SoNextMatchingFeedFiresAgain()
+ {
+ // Once an alert has fired, _alertFired latches true. NotifyUserInteracted clears it
+ // so a subsequent matching feed re-fires.
+ var detector = new AlertDetector("s1", "session");
+ using var firstSignal = new ManualResetEventSlim(false);
+ using var secondSignal = new ManualResetEventSlim(false);
+
+ try
+ {
+ AlertEvent? first = null;
+ Action firstHandler = evt => { first = evt; firstSignal.Set(); };
+ detector.AlertRaised += firstHandler;
+
+ detector.Feed("Do you want to proceed?\n");
+ Assert.True(firstSignal.Wait(WaitForAlertMs), "First alert should have fired.");
+ Assert.NotNull(first);
+ detector.AlertRaised -= firstHandler;
+
+ // Clear, then re-feed; expect a second fire.
+ detector.NotifyUserInteracted();
+
+ AlertEvent? second = null;
+ detector.AlertRaised += evt => { second = evt; secondSignal.Set(); };
+
+ detector.Feed("Allow this action?\n");
+ Assert.True(secondSignal.Wait(WaitForAlertMs), "Second alert should have re-fired after NotifyUserInteracted.");
+ Assert.NotNull(second);
+ Assert.Equal(AlertType.ToolApproval, second!.Type);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- Idle-timer reset behavior -----------------------------------------------------------
+
+ [Fact]
+ public void Feed_LastLineWins_OnlyFinalLineEvaluatedForAlertType()
+ {
+ // Feed() processes all non-empty lines but only the LAST trimmed line is kept in
+ // _lastLine. So if the final line is a tool-approval prompt, that's what we get —
+ // even if earlier lines would also have matched a different type.
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ // Earlier line is an input-required pattern; final line is a tool-approval phrase.
+ detector.Feed("Continue [y/N]\nDo you want to run this?\n");
+
+ Assert.True(signal.Wait(WaitForAlertMs));
+ Assert.NotNull(captured);
+ Assert.Equal(AlertType.ToolApproval, captured!.Type);
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Feed_MessageTruncatedAt100Chars_WhenLineIsLonger()
+ {
+ // Lines longer than 100 chars are truncated with "…".
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ AlertEvent? captured = null;
+ detector.AlertRaised += evt => { captured = evt; signal.Set(); };
+
+ try
+ {
+ // Build a 140-char line ending with an approval phrase.
+ string padding = new string('x', 130);
+ string line = padding + " Continue?";
+ Assert.True(line.Length > 100);
+
+ detector.Feed(line + "\n");
+
+ Assert.True(signal.Wait(WaitForAlertMs));
+ Assert.NotNull(captured);
+ Assert.EndsWith("…", captured!.Message); // U+2026 = …
+ Assert.Equal(101, captured.Message.Length); // 100 chars + the ellipsis
+ }
+ finally
+ {
+ detector.Dispose();
+ }
+ }
+
+ // ---- Dispose -----------------------------------------------------------------------------
+
+ [Fact]
+ public void Dispose_AfterFeed_PreventsAlertFromFiring()
+ {
+ var detector = new AlertDetector("s1", "session");
+ using var signal = new ManualResetEventSlim(false);
+ detector.AlertRaised += _ => signal.Set();
+
+ detector.Feed("Do you want to continue?\n");
+ detector.Dispose();
+
+ Assert.False(signal.Wait(WaitForNoAlertMs),
+ "Disposing should have stopped the pending idle timer.");
+ }
+}
diff --git a/tests/CodeShellManager.Tests/ColorServiceTests.cs b/tests/CodeShellManager.Tests/ColorServiceTests.cs
new file mode 100644
index 0000000..031cb59
--- /dev/null
+++ b/tests/CodeShellManager.Tests/ColorServiceTests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CodeShellManager.Services;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+public class ColorServiceTests
+{
+ // The 12-color palette baked into ColorService. Kept here verbatim so a palette
+ // change in the source forces a deliberate update here too.
+ private static readonly string[] ExpectedPalette =
+ [
+ "#FF6B6B", "#FF9E42", "#FFD166", "#AEDE68",
+ "#51CF66", "#38D9A9", "#66D9E8", "#4DABF7",
+ "#748FFC", "#9775FA", "#F783AC", "#FF6B95",
+ ];
+
+ [Fact]
+ public void GetHexColor_IsDeterministic_SameInputProducesSameColor()
+ {
+ const string key = @"C:\projects\my-app";
+ var first = ColorService.GetHexColor(key);
+ var second = ColorService.GetHexColor(key);
+ Assert.Equal(first, second);
+ }
+
+ [Theory]
+ [InlineData(@"C:\projects\alpha")]
+ [InlineData(@"C:\projects\beta")]
+ [InlineData("user@host.example.com")]
+ [InlineData("")]
+ public void GetHexColor_IsDeterministic_AcrossMultipleCalls(string key)
+ {
+ // Call several times to make sure there's no hidden state.
+ var results = Enumerable.Range(0, 5)
+ .Select(_ => ColorService.GetHexColor(key))
+ .Distinct()
+ .ToList();
+ Assert.Single(results);
+ }
+
+ [Fact]
+ public void GetHexColor_DifferentKeys_MapToDifferentColors_OnSmallSample()
+ {
+ // Not strictly guaranteed by any hash function, but this small handpicked
+ // sample is known to spread across distinct palette entries for FNV-1a.
+ var a = ColorService.GetHexColor(@"C:\proj\a");
+ var b = ColorService.GetHexColor(@"C:\proj\b");
+ Assert.NotEqual(a, b);
+ }
+
+ [Theory]
+ [InlineData(@"C:\projects\my-app")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("user@host:22")]
+ [InlineData(@"\\unc\share\path")]
+ public void GetHexColor_ReturnsValidHexFormat(string key)
+ {
+ var hex = ColorService.GetHexColor(key);
+ Assert.NotNull(hex);
+ Assert.Equal(7, hex.Length);
+ Assert.StartsWith("#", hex);
+ Assert.Matches("^#[0-9A-Fa-f]{6}$", hex);
+ }
+
+ [Fact]
+ public void GetHexColor_ReturnedValue_IsAlwaysFromPalette()
+ {
+ var paletteSet = new HashSet(ExpectedPalette, StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < 200; i++)
+ {
+ var key = $"C:\\repo\\project-{i}";
+ var hex = ColorService.GetHexColor(key);
+ Assert.Contains(hex, paletteSet);
+ }
+ }
+
+ [Fact]
+ public void GetHexColor_DistributesAcrossAllPaletteColors_OverLargeSample()
+ {
+ // Over ~100 distinct keys, FNV-1a should hit every one of the 12 palette
+ // entries at least once. If this becomes flaky, widen the sample — don't
+ // weaken the assertion, since uneven distribution is a real regression signal.
+ var counts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var color in ExpectedPalette) counts[color] = 0;
+
+ for (int i = 0; i < 120; i++)
+ {
+ var key = $"C:\\repo\\project-{i}";
+ counts[ColorService.GetHexColor(key)]++;
+ }
+
+ Assert.All(ExpectedPalette, color =>
+ Assert.True(counts[color] > 0, $"Palette color {color} was never produced over 120 sample keys."));
+ }
+
+ [Fact]
+ public void GetHexColor_IsCaseInsensitive()
+ {
+ // Implementation lower-cases the input via ToLowerInvariant() before hashing,
+ // so paths that differ only by case must map to the same palette color.
+ var upper = ColorService.GetHexColor(@"C:\Foo\Bar");
+ var lower = ColorService.GetHexColor(@"c:\foo\bar");
+ Assert.Equal(upper, lower);
+ }
+
+ [Fact]
+ public void GetHexColor_TrimsTrailingSlashesAndBackslashes()
+ {
+ // Implementation calls TrimEnd('/', '\\') so all four variants must collapse
+ // to the same color.
+ var bare = ColorService.GetHexColor(@"C:\projects\app");
+ var trailingBackslash = ColorService.GetHexColor(@"C:\projects\app\");
+ var trailingForward = ColorService.GetHexColor(@"C:\projects\app/");
+ var trailingMixed = ColorService.GetHexColor(@"C:\projects\app/\");
+
+ Assert.Equal(bare, trailingBackslash);
+ Assert.Equal(bare, trailingForward);
+ Assert.Equal(bare, trailingMixed);
+ }
+
+ [Fact]
+ public void GetHexColor_DoesNotTrimInternalSlashes()
+ {
+ // Only trailing slashes are stripped. A path with internal separators must
+ // produce a different color than the same path with those separators removed.
+ var withSeparators = ColorService.GetHexColor(@"C:\projects\app");
+ var collapsed = ColorService.GetHexColor("Cprojectsapp");
+ Assert.NotEqual(withSeparators, collapsed);
+ }
+
+ [Fact]
+ public void GetHexColor_EmptyString_ReturnsDeterministicPaletteColor()
+ {
+ // Locks current behavior: empty string is a valid input — FNV-1a of "" is the
+ // initial offset basis, so this maps to a specific palette slot. Don't tolerate
+ // a silent change to this without a test failure.
+ var hex = ColorService.GetHexColor(string.Empty);
+ Assert.Contains(hex, ExpectedPalette);
+ // FNV-1a offset basis 2166136261 % 12 == 1 → Palette[1] == "#FF9E42"
+ Assert.Equal("#FF9E42", hex);
+ }
+
+ [Fact]
+ public void GetHexColor_WhitespaceString_ReturnsValidPaletteColor()
+ {
+ // TrimEnd only strips '/' and '\\', so whitespace is preserved and hashed.
+ // Document this rather than fix it — callers feed real folder paths.
+ var hex = ColorService.GetHexColor(" ");
+ Assert.Contains(hex, ExpectedPalette);
+ }
+
+ [Fact]
+ public void GetHexColor_Null_Throws()
+ {
+ // Implementation does folderPath.ToLowerInvariant() without a null check,
+ // which throws NullReferenceException. Lock that contract — callers should
+ // never pass null. If a guard is added later, update this test deliberately.
+ Assert.Throws(() => ColorService.GetHexColor(null!));
+ }
+
+ [Fact]
+ public void GetColor_RoundTripsHexValue()
+ {
+ const string key = @"C:\projects\round-trip";
+ var hex = ColorService.GetHexColor(key);
+ var color = ColorService.GetColor(key);
+
+ var expected = (System.Windows.Media.Color)
+ System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
+
+ Assert.Equal(expected, color);
+ }
+
+ [Fact]
+ public void GetBrush_ProducesBrushWithMatchingColor()
+ {
+ const string key = "user@example.com";
+ var brush = ColorService.GetBrush(key);
+ var color = ColorService.GetColor(key);
+
+ Assert.NotNull(brush);
+ Assert.Equal(color, brush.Color);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs
new file mode 100644
index 0000000..b6d9133
--- /dev/null
+++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs
@@ -0,0 +1,118 @@
+using CodeShellManager.Services;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Headless coverage for the WSL routing helpers in GitService. The live
+/// wsl.exe dispatch can't run on every test host; these cover the path/arg
+/// translation that has to be exactly right for routing to land in the
+/// correct place.
+///
+public class GitServiceWslRoutingTests
+{
+ [Theory]
+ [InlineData(@"\\wsl$\Ubuntu\home\alice", "Ubuntu", "/home/alice")]
+ [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")]
+ [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")]
+ [InlineData(@"C:\proj", null, "")]
+ [InlineData("", null, "")]
+ public void TryParseWslUnc_KnownShapes(string path, string? expectedDistro, string expectedLinux)
+ {
+ var (distro, linuxPath) = GitService.TryParseWslUnc(path);
+ Assert.Equal(expectedDistro, distro);
+ Assert.Equal(expectedLinux, linuxPath);
+ }
+
+ [Fact]
+ public void TranslateUncArgsToLinux_MatchingDistro_Substitutes()
+ {
+ string args = "worktree add \"\\\\wsl$\\Ubuntu\\home\\alice\\proj-foo\" main";
+ string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu");
+ Assert.Contains("/home/alice/proj-foo", translated);
+ Assert.DoesNotContain(@"\\wsl$\Ubuntu", translated);
+ }
+
+ [Fact]
+ public void TranslateUncArgsToLinux_DifferentDistro_LeftAlone()
+ {
+ // We're running git inside Ubuntu — a UNC pointing at Debian is a real
+ // mistake and should NOT be silently rewritten to look like a local path.
+ string args = @"worktree add \\wsl$\Debian\home\alice\proj main";
+ string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu");
+ Assert.Equal(args, translated);
+ }
+
+ [Fact]
+ public void TranslateUncArgsToLinux_NoUncs_Passthrough()
+ {
+ string args = "branch --show-current";
+ Assert.Equal(args, GitService.TranslateUncArgsToLinux(args, "Ubuntu"));
+ }
+
+ [Fact]
+ public void TranslateLinuxPathsToUnc_RevParseOutput()
+ {
+ string raw = "/home/alice/proj/.git\n";
+ string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu");
+ Assert.Contains(@"\\wsl$\Ubuntu\home\alice\proj\.git", translated);
+ }
+
+ [Fact]
+ public void TranslateLinuxPathsToUnc_WorktreeListPorcelain()
+ {
+ // Real-ish output: only the `worktree /…` lines carry abs paths; the rest
+ // (HEAD sha, refs/heads/x) must NOT be mangled.
+ string raw = "worktree /home/alice/proj\nHEAD abc123\nbranch refs/heads/main\n";
+ string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu");
+ Assert.Contains(@"worktree \\wsl$\Ubuntu\home\alice\proj", translated);
+ Assert.Contains("HEAD abc123", translated);
+ Assert.Contains("branch refs/heads/main", translated);
+ }
+
+ [Fact]
+ public void TranslateLinuxPathsToUnc_BranchNameWithSlash_NotMangled()
+ {
+ // refs/heads/feature/foo starts with 'r', not '/' — must pass through.
+ string raw = "feature/wsl-sessions\n";
+ Assert.Equal(raw, GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu"));
+ }
+
+ [Fact]
+ public void TranslateLinuxPathsToUnc_StatusPorcelain_Untouched()
+ {
+ // Each "M file" / "?? new" line has no leading slash and shouldn't change.
+ string raw = "M README.md\n?? new.txt\n";
+ Assert.Equal(raw, GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu"));
+ }
+
+ [Fact]
+ public void TranslateUncArgsToLinux_QuotedUncWithSpaces_TranslatedWholeAndReQuoted()
+ {
+ // Regression: the unquoted regex stops at whitespace, so a quoted UNC
+ // containing a space (worktree add target) used to be half-translated.
+ string args = "worktree add \"\\\\wsl$\\Ubuntu\\home\\alice\\my repo\" main";
+ string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu");
+ Assert.Contains("\"/home/alice/my repo\"", translated);
+ Assert.DoesNotContain(@"\\wsl$\Ubuntu", translated);
+ }
+
+ [Fact]
+ public void TranslateUncArgsToLinux_QuotedUncRoot_BecomesQuotedRoot()
+ {
+ string args = "rev-parse \"\\\\wsl$\\Ubuntu\"";
+ string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu");
+ Assert.Equal("rev-parse \"/\"", translated);
+ }
+
+ [Fact]
+ public void TranslateLinuxPathsToUnc_PathContainsSpaces_TranslatesWholePath()
+ {
+ // Regression: the tail used to stop at the first whitespace, so a worktree
+ // path with a space got half-translated.
+ string raw = "worktree /home/alice/My Projects/repo\n";
+ string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu");
+ Assert.Contains(@"\\wsl$\Ubuntu\home\alice\My Projects\repo", translated);
+ Assert.DoesNotContain("Projects/repo", translated); // no leftover forward slashes
+ }
+}
diff --git a/tests/CodeShellManager.Tests/NewSessionDialogTests.cs b/tests/CodeShellManager.Tests/NewSessionDialogTests.cs
new file mode 100644
index 0000000..f48addd
--- /dev/null
+++ b/tests/CodeShellManager.Tests/NewSessionDialogTests.cs
@@ -0,0 +1,33 @@
+using CodeShellManager.Views;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Headless coverage for the bits of that don't
+/// require a window (parsing helpers). UI-level behavior lives in UITests.
+///
+public class NewSessionDialogTests
+{
+ [Theory]
+ [InlineData(@"\\wsl$\Ubuntu\home\alice\proj", "Ubuntu", "/home/alice/proj")]
+ [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")]
+ [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "")]
+ [InlineData(@"\\wsl$\Ubuntu\", "Ubuntu", "")]
+ [InlineData(@"C:\proj", "", "")]
+ [InlineData("", "", "")]
+ public void ParseWslUncPath_KnownShapes(string unc, string expectedDistro, string expectedLinux)
+ {
+ var (distro, linuxPath) = NewSessionDialog.ParseWslUncPath(unc);
+ Assert.Equal(expectedDistro, distro);
+ Assert.Equal(expectedLinux, linuxPath);
+ }
+
+ [Fact]
+ public void ParseWslUncPath_ForwardSlashes_Normalized()
+ {
+ var (distro, linuxPath) = NewSessionDialog.ParseWslUncPath(@"//wsl$/Ubuntu/home/alice");
+ Assert.Equal("Ubuntu", distro);
+ Assert.Equal("/home/alice", linuxPath);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/OutputIndexerTests.cs b/tests/CodeShellManager.Tests/OutputIndexerTests.cs
new file mode 100644
index 0000000..33dc7e4
--- /dev/null
+++ b/tests/CodeShellManager.Tests/OutputIndexerTests.cs
@@ -0,0 +1,168 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using CodeShellManager.Terminal;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Tests for the ANSI-stripping regex used by before
+/// feeding lines into the FTS5 index. The regex is a private static partial
+/// method generated by [GeneratedRegex], so it's reached via reflection.
+///
+/// The pattern (as of writing) is:
+///
+/// \x1B\[[?0-9;]*[mGKHFJABCDsuhl] // CSI: ESC[ ... final-byte
+/// | \x1B\].*?(?:\x07|\x1B\\) // OSC: ESC] ... BEL or ESC\ (ST)
+/// | \x1B[=>] // DECKPAM / DECKPNM (ESC= / ESC>)
+/// | \r // carriage return
+///
+///
+/// Note: this regex deliberately handles only the subset of ANSI control sequences
+/// that ConPTY + xterm.js commonly emit. CSI sequences whose final byte is outside
+/// [mGKHFJABCDsuhl] (for example DEC private-mode n, r, t,
+/// or device-status reports) are intentionally left in the indexed text.
+///
+/// String-literal note: ESC and BEL are written as /
+/// (fixed 4-digit form). The variable-length \x escape would greedily eat
+/// any hex letter that follows (e.g. "\x1Bb" parses as the single
+/// character U+01BB, not ESC + 'b').
+///
+public class OutputIndexerTests
+{
+ private const string ESC = "";
+ private const string BEL = "";
+
+ private static readonly Regex Ansi = InvokeAnsiPattern();
+
+ private static Regex InvokeAnsiPattern()
+ {
+ var mi = typeof(OutputIndexer).GetMethod(
+ "AnsiPattern",
+ BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.NotNull(mi);
+ var rx = (Regex?)mi!.Invoke(null, null);
+ Assert.NotNull(rx);
+ return rx!;
+ }
+
+ private static string Strip(string input) => Ansi.Replace(input, "");
+
+ [Fact]
+ public void Strip_BasicCsiColor_RemovesEscapes()
+ {
+ Assert.Equal("red", Strip($"{ESC}[31mred{ESC}[0m"));
+ }
+
+ [Fact]
+ public void Strip_CsiWithMultipleParameters_RemovesEscapes()
+ {
+ Assert.Equal("bold green", Strip($"{ESC}[1;32mbold green{ESC}[0m"));
+ }
+
+ [Fact]
+ public void Strip_CsiCursorMotion_ClearScreenAndHome_AreRemoved()
+ {
+ // ESC[2J = clear screen, ESC[H = cursor home
+ Assert.Equal("", Strip($"{ESC}[2J{ESC}[H"));
+ }
+
+ [Fact]
+ public void Strip_CsiCursorMovementSequences_AreRemoved()
+ {
+ // A/B/C/D = cursor up/down/forward/back, G = horizontal absolute,
+ // K = erase in line, F = previous-line, s/u = save/restore cursor.
+ var input = $"x{ESC}[2Ay{ESC}[3Bz{ESC}[4Cw{ESC}[5Dv{ESC}[10Gu{ESC}[Kt{ESC}[Fs{ESC}[sr{ESC}[uq";
+ Assert.Equal("xyzwvutsrq", Strip(input));
+ }
+
+ [Fact]
+ public void Strip_CsiPrivateMode_AltScreenAndBracketedPaste_AreRemoved()
+ {
+ // ESC[?1049h = enable alt screen, ESC[?2004h = bracketed paste on,
+ // ESC[?2004l = bracketed paste off. The `?` is matched by [?0-9;]*.
+ Assert.Equal("body", Strip($"{ESC}[?1049h{ESC}[?2004hbody{ESC}[?2004l{ESC}[?1049l"));
+ }
+
+ [Fact]
+ public void Strip_OscWithBelTerminator_RemovesSequence()
+ {
+ // Set window title: ESC]0;title BEL
+ Assert.Equal("rest", Strip($"{ESC}]0;title{BEL}rest"));
+ }
+
+ [Fact]
+ public void Strip_OscWithStringTerminator_RemovesSequence()
+ {
+ // String Terminator (ST) = ESC \. The regex's OSC arm accepts both BEL
+ // and ESC\, so this should also be stripped.
+ Assert.Equal("rest", Strip($"{ESC}]0;title{ESC}\\rest"));
+ }
+
+ [Fact]
+ public void Strip_OscIsNonGreedy_DoesNotEatPastFirstTerminator()
+ {
+ // Two OSCs back-to-back: the non-greedy `.*?` should stop at the first BEL.
+ Assert.Equal("middleend", Strip($"{ESC}]0;a{BEL}middle{ESC}]0;b{BEL}end"));
+ }
+
+ [Fact]
+ public void Strip_CarriageReturn_IsRemoved()
+ {
+ Assert.Equal("ab", Strip("a\rb"));
+ }
+
+ [Fact]
+ public void Strip_CarriageReturnPreservesNewline()
+ {
+ // CRLF -> LF (CR removed, LF kept). Feed() relies on this so that the
+ // \n split produces clean lines without trailing \r.
+ Assert.Equal("line1\nline2", Strip("line1\r\nline2"));
+ }
+
+ [Fact]
+ public void Strip_KeypadModeEscapes_AreRemoved()
+ {
+ // ESC= (DECKPAM) and ESC> (DECKPNM) — two-byte escapes ConPTY uses.
+ Assert.Equal("hello", Strip($"{ESC}=hel{ESC}>lo"));
+ }
+
+ [Fact]
+ public void Strip_PlainText_IsUnchanged()
+ {
+ Assert.Equal("hello world", Strip("hello world"));
+ }
+
+ [Fact]
+ public void Strip_EmptyInput_ReturnsEmpty()
+ {
+ Assert.Equal("", Strip(""));
+ }
+
+ [Fact]
+ public void Strip_MixedAnsiAndPlain_RemovesOnlyEscapes()
+ {
+ var input = $"{ESC}[31mError:{ESC}[0m something {ESC}[1;33mwarning{ESC}[0m done";
+ Assert.Equal("Error: something warning done", Strip(input));
+ }
+
+ [Fact]
+ public void Strip_LeavesUnsupportedCsiFinalsIntact()
+ {
+ // Documenting current behavior: the regex's CSI arm only accepts finals
+ // in [mGKHFJABCDsuhl]. A device-status query like ESC[6n (final 'n') is
+ // NOT stripped. If the regex is later widened, update this test.
+ var input = $"before{ESC}[6nafter";
+ // The escape sequence remains intact because the CSI arm requires a
+ // matching final byte that we don't list.
+ Assert.Contains($"{ESC}[6n", Strip(input));
+ }
+
+ [Fact]
+ public void Strip_LoneEscapeWithoutSequence_IsLeftAlone()
+ {
+ // A bare ESC with no [, ], =, or > following it is not matched by any
+ // arm of the regex. Documenting that as intentional.
+ Assert.Equal($"a{ESC}b", Strip($"a{ESC}b"));
+ }
+}
diff --git a/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs b/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs
new file mode 100644
index 0000000..8a2cfda
--- /dev/null
+++ b/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs
@@ -0,0 +1,132 @@
+using System.Linq;
+using CodeShellManager.Models;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+public class RecentlyClosedEntryTests
+{
+ [Fact]
+ public void FromSession_CopiesScalarAndSshFields()
+ {
+ var s = new ShellSession
+ {
+ Name = "alpha",
+ WorkingFolder = @"C:\proj",
+ Command = "claude",
+ Args = "--resume foo",
+ GroupId = "g1",
+ ColorOverride = "#abcdef",
+ IsRemote = true,
+ SshUser = "alice",
+ SshHost = "dev.example.com",
+ SshPort = 2222,
+ SshRemoteFolder = "/srv",
+ };
+
+ var e = RecentlyClosedEntry.FromSession(s);
+
+ Assert.Equal("alpha", e.Name);
+ Assert.Equal(@"C:\proj", e.WorkingFolder);
+ Assert.Equal("claude", e.Command);
+ Assert.Equal("--resume foo", e.Args);
+ Assert.Equal("g1", e.GroupId);
+ Assert.Equal("#abcdef", e.ColorOverride);
+ Assert.True(e.IsRemote);
+ Assert.Equal("alice", e.SshUser);
+ Assert.Equal("dev.example.com", e.SshHost);
+ Assert.Equal(2222, e.SshPort);
+ Assert.Equal("/srv", e.SshRemoteFolder);
+ }
+
+ [Fact]
+ public void FromSession_CopiesProfileOverrides()
+ {
+ var s = new ShellSession
+ {
+ ProfileFontFamily = "Cascadia",
+ ProfileFontSize = 16,
+ ProfileFontWeight = "bold",
+ ProfileFontLigatures = true,
+ ProfileCursorShape = "underline",
+ ProfileCursorBlink = false,
+ ProfilePadding = "8 8 8 8",
+ ProfileBackgroundOpacity = 0.85,
+ ProfileRetroEffect = true,
+ ProfileColorSchemeJson = "{\"foreground\":\"#fff\"}",
+ };
+
+ var e = RecentlyClosedEntry.FromSession(s);
+
+ Assert.Equal("Cascadia", e.ProfileFontFamily);
+ Assert.Equal(16, e.ProfileFontSize);
+ Assert.Equal("bold", e.ProfileFontWeight);
+ Assert.True(e.ProfileFontLigatures);
+ Assert.Equal("underline", e.ProfileCursorShape);
+ Assert.False(e.ProfileCursorBlink);
+ Assert.Equal("8 8 8 8", e.ProfilePadding);
+ Assert.Equal(0.85, e.ProfileBackgroundOpacity);
+ Assert.True(e.ProfileRetroEffect);
+ Assert.Equal("{\"foreground\":\"#fff\"}", e.ProfileColorSchemeJson);
+ }
+
+ [Fact]
+ public void FromSession_DeepCopiesRunCommandsWithFreshIds()
+ {
+ var s = new ShellSession();
+ s.RunCommands.Add(new RunCommandItem
+ {
+ Id = "original-id",
+ Label = "Test",
+ CommandLine = "dotnet test",
+ IsDefault = true,
+ Mode = RunMode.PowerShell,
+ PostRunUrl = "http://localhost:5173",
+ });
+
+ var e = RecentlyClosedEntry.FromSession(s);
+
+ Assert.Single(e.RunCommands);
+ var copy = e.RunCommands[0];
+ Assert.NotEqual("original-id", copy.Id); // fresh Id
+ Assert.Equal("Test", copy.Label);
+ Assert.Equal("dotnet test", copy.CommandLine);
+ Assert.True(copy.IsDefault);
+ Assert.Equal(RunMode.PowerShell, copy.Mode);
+ Assert.Equal("http://localhost:5173", copy.PostRunUrl);
+ // Mutating the copy must NOT touch the source.
+ copy.Label = "MUTATED";
+ Assert.Equal("Test", s.RunCommands[0].Label);
+ }
+
+ [Fact]
+ public void Subtitle_RemoteSession_ReturnsUserAtHost()
+ {
+ var e = new RecentlyClosedEntry
+ {
+ IsRemote = true,
+ SshUser = "bob",
+ SshHost = "dev.local",
+ WorkingFolder = @"C:\should-be-ignored",
+ };
+ Assert.Equal("bob@dev.local", e.Subtitle);
+ }
+
+ [Fact]
+ public void Subtitle_RemoteSessionWithoutUser_ReturnsHostOnly()
+ {
+ var e = new RecentlyClosedEntry
+ {
+ IsRemote = true,
+ SshHost = "dev.local",
+ };
+ Assert.Equal("dev.local", e.Subtitle);
+ }
+
+ [Fact]
+ public void Subtitle_LocalSession_ReturnsWorkingFolder()
+ {
+ var e = new RecentlyClosedEntry { WorkingFolder = @"C:\proj" };
+ Assert.Equal(@"C:\proj", e.Subtitle);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/RunCommandItemTests.cs b/tests/CodeShellManager.Tests/RunCommandItemTests.cs
index 20747bb..84a02ea 100644
--- a/tests/CodeShellManager.Tests/RunCommandItemTests.cs
+++ b/tests/CodeShellManager.Tests/RunCommandItemTests.cs
@@ -43,4 +43,14 @@ public void EnsureSingleDefault_EmptyList_NoOp()
RunCommandItem.EnsureSingleDefault(list);
Assert.Empty(list);
}
+
+ [Fact]
+ public void Defaults_ModeIsProcess_PostRunUrlIsNull()
+ {
+ // Back-compat: legacy state.json files that lack Mode/PostRunUrl must round-trip
+ // to "Process" and null respectively so existing run commands keep working.
+ var item = new RunCommandItem();
+ Assert.Equal(RunMode.Process, item.Mode);
+ Assert.Null(item.PostRunUrl);
+ }
}
diff --git a/tests/CodeShellManager.Tests/RunInstanceTests.cs b/tests/CodeShellManager.Tests/RunInstanceTests.cs
index 5e608ad..d427355 100644
--- a/tests/CodeShellManager.Tests/RunInstanceTests.cs
+++ b/tests/CodeShellManager.Tests/RunInstanceTests.cs
@@ -64,4 +64,62 @@ public void BuildSshArgs_CommandLineWithApostrophe_IsEscaped()
string args = RunInstance.BuildSshArgs(p, "echo it's me");
Assert.Contains(@"bash -c 'echo it'\''s me'", args);
}
+
+ [Fact]
+ public void BuildPwshArgs_RoundTripsCommandLineViaBase64()
+ {
+ // -EncodedCommand expects UTF-16 LE base64. Round-trip a known string with
+ // tricky chars ($env: would otherwise be eaten by cmd.exe parsing) to
+ // confirm the wrapping is preserved verbatim.
+ const string cmd = "Write-Host \"hi $env:USERNAME!\" | Out-Default";
+ string args = RunInstance.BuildPwshArgs(cmd);
+
+ Assert.StartsWith("-NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ", args);
+ string b64 = args.Substring(args.LastIndexOf(' ') + 1);
+ string decoded = System.Text.Encoding.Unicode.GetString(System.Convert.FromBase64String(b64));
+ Assert.Equal(cmd, decoded);
+ }
+
+ [Fact]
+ public void BuildWslArgs_HappyPath_BuildsExpectedShape()
+ {
+ var p = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslUser = "alice",
+ WslWorkingFolder = "/home/alice/proj",
+ };
+ string args = RunInstance.BuildWslArgs(p, "cargo test");
+ // Double quotes (Windows-side grouping) — single quotes would leak through
+ // Windows command-line tokenization and reach bash as broken token pieces.
+ Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc \"cargo test\"", args);
+ }
+
+ [Fact]
+ public void BuildWslArgs_NoUserOrFolder_OmitsFlags()
+ {
+ var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Debian" };
+ string args = RunInstance.BuildWslArgs(p, "ls");
+ Assert.Equal("-d Debian -- bash -lc \"ls\"", args);
+ }
+
+ [Fact]
+ public void BuildWslArgs_CommandLineWithEmbeddedDoubleQuote_Escapes()
+ {
+ var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" };
+ string args = RunInstance.BuildWslArgs(p, "echo \"hi\"");
+ Assert.Contains("bash -lc \"echo \\\"hi\\\"\"", args);
+ }
+
+ [Fact]
+ public void BuildWslArgs_CommandLineWithApostrophe_PassesThroughVerbatim()
+ {
+ // Apostrophes need no escaping from us — the outer wrapper is "..." so
+ // Windows tokenization keeps the whole commandLine as one argv entry, and
+ // bash then sees the apostrophe at face value. (What bash does with an
+ // unbalanced apostrophe is the caller's problem; we just refuse to mangle
+ // it during arg-building.)
+ var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" };
+ string args = RunInstance.BuildWslArgs(p, "echo it's me");
+ Assert.Contains("bash -lc \"echo it's me\"", args);
+ }
}
diff --git a/tests/CodeShellManager.Tests/SearchServiceTests.cs b/tests/CodeShellManager.Tests/SearchServiceTests.cs
new file mode 100644
index 0000000..5a4867d
--- /dev/null
+++ b/tests/CodeShellManager.Tests/SearchServiceTests.cs
@@ -0,0 +1,431 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using CodeShellManager.Services;
+using Microsoft.Data.Sqlite;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Tests for . Each test gets its own temporary SQLite
+/// file ( takes a directly,
+/// so we open a fresh file-backed connection, run ,
+/// seed rows via the same INSERT statement OutputIndexer uses, and delete the
+/// file on dispose). File-backed (not :memory:) so all `_db.CreateCommand()`
+/// calls inside the service see the same data — though SearchService holds one
+/// connection so :memory: would technically work too.
+///
+public class SearchServiceTests : IDisposable
+{
+ private readonly string _dbPath;
+ private readonly SqliteConnection _db;
+ private readonly SearchService _svc;
+
+ public SearchServiceTests()
+ {
+ _dbPath = Path.Combine(Path.GetTempPath(), $"csm-search-{Guid.NewGuid():N}.db");
+ _db = new SqliteConnection($"Data Source={_dbPath}");
+ _db.Open();
+ SearchService.InitializeSchemaAsync(_db).GetAwaiter().GetResult();
+ _svc = new SearchService(_db);
+ }
+
+ public void Dispose()
+ {
+ try { _db.Close(); } catch { }
+ try { _db.Dispose(); } catch { }
+ // SQLite holds an exclusive lock on Windows until the connection is fully
+ // released and pooling is cleared.
+ try { SqliteConnection.ClearAllPools(); } catch { }
+ try
+ {
+ if (File.Exists(_dbPath)) File.Delete(_dbPath);
+ }
+ catch { /* best-effort cleanup */ }
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private async Task SeedOutputAsync(string sessionId, string sessionName, string line, long? tsMs = null)
+ {
+ await using var cmd = _db.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO session_output (session_id, session_name, ts, line)
+ VALUES ($sid, $sname, $ts, $line)
+ """;
+ cmd.Parameters.AddWithValue("$sid", sessionId);
+ cmd.Parameters.AddWithValue("$sname", sessionName);
+ cmd.Parameters.AddWithValue("$ts", tsMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ cmd.Parameters.AddWithValue("$line", line);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ // ── SearchAsync — output (FTS5) ──────────────────────────────────────────
+
+ [Fact]
+ public async Task SearchAsync_EmptyDatabase_ReturnsNoResults()
+ {
+ var results = await _svc.SearchAsync("anything");
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public async Task SearchAsync_BlankQuery_ReturnsNoResults()
+ {
+ await SeedOutputAsync("s1", "Session 1", "hello world");
+ var results = await _svc.SearchAsync(" ");
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public async Task SearchAsync_UniqueToken_ReturnsOneResultWithSessionIdAndSnippet()
+ {
+ await SeedOutputAsync("sess-A", "Alpha", "the quick brown fox");
+ await SeedOutputAsync("sess-B", "Beta", "lazy dog jumps over");
+ await SeedOutputAsync("sess-C", "Gamma", "nothing relevant here");
+
+ var results = await _svc.SearchAsync("quick");
+
+ Assert.Single(results);
+ var hit = results[0];
+ Assert.Equal("sess-A", hit.SessionId);
+ Assert.Equal("Alpha", hit.SessionName);
+ Assert.False(string.IsNullOrEmpty(hit.Snippet));
+ Assert.Equal(SearchResultType.Output, hit.Type);
+ // snippet uses '[' / ']' markers around the matched token
+ Assert.Contains("[quick]", hit.Snippet, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task SearchAsync_MultiWordQuery_AppliesImplicitAnd()
+ {
+ await SeedOutputAsync("s1", "S1", "foo bar baz");
+ await SeedOutputAsync("s2", "S2", "foo only");
+ await SeedOutputAsync("s3", "S3", "bar only");
+
+ var results = await _svc.SearchAsync("foo bar");
+
+ // FTS5 default is implicit AND — only s1 has both tokens
+ Assert.Single(results);
+ Assert.Equal("s1", results[0].SessionId);
+ }
+
+ [Fact]
+ public async Task SearchAsync_NoMatch_ReturnsEmpty()
+ {
+ await SeedOutputAsync("s1", "S1", "hello world");
+ var results = await _svc.SearchAsync("zzznotfoundzzz");
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public async Task SearchAsync_RespectsLimit()
+ {
+ for (int i = 0; i < 5; i++)
+ await SeedOutputAsync($"s{i}", $"S{i}", $"matchtoken row {i}");
+
+ var results = await _svc.SearchAsync("matchtoken", limit: 3);
+
+ Assert.True(results.Count <= 3, $"expected <= 3 results, got {results.Count}");
+ Assert.Equal(3, results.Count);
+ }
+
+ [Fact]
+ public async Task SearchAsync_SnippetContainsMatchedToken()
+ {
+ await SeedOutputAsync("s1", "S1", "before middle needle middle after");
+ var results = await _svc.SearchAsync("needle");
+ Assert.Single(results);
+ // snippet wraps the matched token in brackets
+ Assert.Contains("needle", results[0].Snippet);
+ }
+
+ [Fact]
+ public async Task SearchAsync_OrdersByTimestampDescending()
+ {
+ long t0 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ await SeedOutputAsync("old", "Old", "needle old", tsMs: t0 - 60_000);
+ await SeedOutputAsync("new", "New", "needle new", tsMs: t0);
+ await SeedOutputAsync("mid", "Mid", "needle mid", tsMs: t0 - 30_000);
+
+ var results = await _svc.SearchAsync("needle");
+
+ Assert.Equal(3, results.Count);
+ Assert.Equal("new", results[0].SessionId);
+ Assert.Equal("mid", results[1].SessionId);
+ Assert.Equal("old", results[2].SessionId);
+ }
+
+ [Fact]
+ public async Task SearchAsync_MalformedFtsQuery_DoesNotThrow()
+ {
+ await SeedOutputAsync("s1", "S1", "anything");
+ // unbalanced quote → FTS5 syntax error inside SearchAsync's try/catch
+ var results = await _svc.SearchAsync("\"unterminated");
+ Assert.NotNull(results);
+ // FTS error is swallowed → output search yields nothing; notes search runs too but finds nothing
+ Assert.Empty(results);
+ }
+
+ // ── SearchAsync — project notes ──────────────────────────────────────────
+
+ [Fact]
+ public async Task SearchAsync_MatchesProjectNotes()
+ {
+ await _svc.SaveNoteAsync(@"C:\repos\myproject", "remember to fix the unique-thing later");
+
+ var results = await _svc.SearchAsync("unique-thing");
+
+ var note = Assert.Single(results, r => r.Type == SearchResultType.Note);
+ Assert.Equal("myproject", note.SessionName);
+ Assert.Equal(@"C:\repos\myproject", note.FolderPath);
+ Assert.Contains("[unique-thing]", note.Snippet);
+ Assert.True(note.IsNote);
+ Assert.Equal("note", note.TypeLabel);
+ }
+
+ // ── Project notes ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetNoteAsync_MissingFolder_ReturnsNull()
+ {
+ var note = await _svc.GetNoteAsync(@"C:\does\not\exist");
+ Assert.Null(note);
+ }
+
+ [Fact]
+ public async Task SaveNoteAsync_ThenGet_RoundTrips()
+ {
+ await _svc.SaveNoteAsync(@"C:\repos\foo", "hello note");
+ var note = await _svc.GetNoteAsync(@"C:\repos\foo");
+ Assert.Equal("hello note", note);
+ }
+
+ [Fact]
+ public async Task SaveNoteAsync_Upserts()
+ {
+ await _svc.SaveNoteAsync(@"C:\repos\foo", "first");
+ await _svc.SaveNoteAsync(@"C:\repos\foo", "second");
+ var note = await _svc.GetNoteAsync(@"C:\repos\foo");
+ Assert.Equal("second", note);
+ }
+
+ // ── Session history ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetSessionHistoryAsync_MissingId_ReturnsNull()
+ {
+ var entry = await _svc.GetSessionHistoryAsync("nope");
+ Assert.Null(entry);
+ }
+
+ [Fact]
+ public async Task RecordSessionHistoryAsync_ThenGet_RoundTrips()
+ {
+ await _svc.RecordSessionHistoryAsync(
+ sessionId: "sid-1",
+ sessionName: "My Session",
+ workingFolder: @"C:\repos\proj",
+ command: "claude",
+ args: "--resume xyz",
+ groupId: "grp-1");
+
+ var entry = await _svc.GetSessionHistoryAsync("sid-1");
+
+ Assert.NotNull(entry);
+ Assert.Equal("sid-1", entry!.SessionId);
+ Assert.Equal("My Session", entry.SessionName);
+ Assert.Equal(@"C:\repos\proj", entry.WorkingFolder);
+ Assert.Equal("claude", entry.Command);
+ Assert.Equal("--resume xyz", entry.Args);
+ Assert.Equal("grp-1", entry.GroupId);
+ }
+
+ [Fact]
+ public async Task GetLatestSessionHistoryForFolderAsync_ReturnsMostRecent()
+ {
+ // Seed timestamps directly rather than relying on Task.Delay between RecordSessionHistoryAsync
+ // calls — Windows' default 15.6ms timer resolution can collide millisecond unix timestamps
+ // on slow CI workers, making "most recent" nondeterministic.
+ await SeedSessionHistoryAsync("sid-old", "Old", @"C:\proj", "claude", tsMs: 1000);
+ await SeedSessionHistoryAsync("sid-new", "New", @"C:\proj", "claude", tsMs: 2000);
+
+ var entry = await _svc.GetLatestSessionHistoryForFolderAsync(@"C:\proj");
+
+ Assert.NotNull(entry);
+ Assert.Equal("sid-new", entry!.SessionId);
+ }
+
+ private async Task SeedSessionHistoryAsync(string sessionId, string sessionName, string workingFolder, string command, long tsMs)
+ {
+ await using var cmd = _db.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO session_history
+ (session_id, session_name, working_folder, command, args, group_id, exited_at)
+ VALUES ($sid, $name, $folder, $cmd, '', '', $ts)
+ """;
+ cmd.Parameters.AddWithValue("$sid", sessionId);
+ cmd.Parameters.AddWithValue("$name", sessionName);
+ cmd.Parameters.AddWithValue("$folder", workingFolder);
+ cmd.Parameters.AddWithValue("$cmd", command);
+ cmd.Parameters.AddWithValue("$ts", tsMs);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ [Fact]
+ public async Task GetLatestSessionHistoryForFolderAsync_NoMatch_ReturnsNull()
+ {
+ await _svc.RecordSessionHistoryAsync("sid", "S", @"C:\a", "claude", "", "");
+ var entry = await _svc.GetLatestSessionHistoryForFolderAsync(@"C:\different");
+ Assert.Null(entry);
+ }
+
+ // ── Storage management ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DeleteSessionLogsAsync_RemovesOnlyMatchingSession()
+ {
+ await SeedOutputAsync("keep", "Keep", "alpha line");
+ await SeedOutputAsync("drop", "Drop", "alpha line");
+
+ await _svc.DeleteSessionLogsAsync("drop");
+ var results = await _svc.SearchAsync("alpha");
+
+ Assert.Single(results);
+ Assert.Equal("keep", results[0].SessionId);
+ }
+
+ [Fact]
+ public async Task PruneOldOutputAsync_NonPositiveRetention_NoOp()
+ {
+ await SeedOutputAsync("s1", "S1", "alpha");
+ Assert.Equal(0, await _svc.PruneOldOutputAsync(0));
+ Assert.Equal(0, await _svc.PruneOldOutputAsync(-5));
+ var results = await _svc.SearchAsync("alpha");
+ Assert.Single(results);
+ }
+
+ [Fact]
+ public async Task PruneOldOutputAsync_DeletesRowsOlderThanCutoff()
+ {
+ long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ long oldTs = now - TimeSpan.FromDays(30).Ticks / TimeSpan.TicksPerMillisecond;
+
+ await SeedOutputAsync("recent", "R", "alpha recent", tsMs: now);
+ await SeedOutputAsync("old", "O", "alpha old", tsMs: oldTs);
+
+ int deleted = await _svc.PruneOldOutputAsync(retentionDays: 7);
+
+ Assert.Equal(1, deleted);
+ var results = await _svc.SearchAsync("alpha");
+ Assert.Single(results);
+ Assert.Equal("recent", results[0].SessionId);
+ }
+
+ [Fact]
+ public async Task ClearAllOutputAsync_WipesEverything()
+ {
+ await SeedOutputAsync("s1", "S1", "alpha");
+ await SeedOutputAsync("s2", "S2", "beta");
+
+ await _svc.ClearAllOutputAsync();
+ var resultsA = await _svc.SearchAsync("alpha");
+ var resultsB = await _svc.SearchAsync("beta");
+
+ Assert.Empty(resultsA);
+ Assert.Empty(resultsB);
+ }
+
+ [Fact]
+ public async Task GetDatabaseSizeBytesAsync_ReturnsPositiveSize()
+ {
+ await SeedOutputAsync("s1", "S1", "some line");
+ long size = await _svc.GetDatabaseSizeBytesAsync();
+ Assert.True(size > 0, $"expected size > 0, got {size}");
+ }
+
+ // ── Usage stats ─────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("claude --continue", "claude")]
+ [InlineData("CLAUDE", "claude")]
+ [InlineData("pwsh", "pwsh")]
+ [InlineData("pwsh -NoLogo", "pwsh")]
+ [InlineData("", "(unknown)")]
+ [InlineData(null, "(unknown)")]
+ [InlineData(" ", "(unknown)")]
+ public void NormalizeCommandName_BehavesAsExpected(string? input, string expected)
+ {
+ Assert.Equal(expected, SearchService.NormalizeCommandName(input));
+ }
+
+ [Fact]
+ public async Task RecordSessionStartAsync_IncrementsSessionCount()
+ {
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionStartAsync("claude --resume abc");
+ await _svc.RecordSessionStartAsync("pwsh");
+
+ var stats = await _svc.GetUsageStatsAsync();
+
+ var claude = stats.Single(s => s.Command == "claude");
+ var pwsh = stats.Single(s => s.Command == "pwsh");
+ Assert.Equal(2, claude.Sessions);
+ Assert.Equal(1, pwsh.Sessions);
+ }
+
+ [Fact]
+ public async Task RecordSessionDurationAsync_AccumulatesSeconds()
+ {
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionDurationAsync("claude", 120);
+ await _svc.RecordSessionDurationAsync("claude --resume", 60);
+
+ var stats = await _svc.GetUsageStatsAsync();
+ var claude = stats.Single(s => s.Command == "claude");
+ Assert.Equal(180, claude.TotalSeconds);
+ }
+
+ [Fact]
+ public async Task RecordSessionDurationAsync_NonPositive_NoOp()
+ {
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionDurationAsync("claude", 0);
+ await _svc.RecordSessionDurationAsync("claude", -10);
+
+ var stats = await _svc.GetUsageStatsAsync();
+ var claude = stats.Single(s => s.Command == "claude");
+ Assert.Equal(0, claude.TotalSeconds);
+ }
+
+ [Fact]
+ public async Task GetUsageStatsAsync_EmptyDb_ReturnsEmptyList()
+ {
+ var stats = await _svc.GetUsageStatsAsync();
+ Assert.Empty(stats);
+ }
+
+ [Fact]
+ public async Task GetUsageStatsAsync_OrdersBySessionsDescending()
+ {
+ // claude: 3 starts; pwsh: 1 start; bash: 2 starts
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionStartAsync("claude");
+ await _svc.RecordSessionStartAsync("pwsh");
+ await _svc.RecordSessionStartAsync("bash");
+ await _svc.RecordSessionStartAsync("bash");
+
+ var stats = await _svc.GetUsageStatsAsync();
+
+ Assert.Equal(3, stats.Count);
+ Assert.Equal("claude", stats[0].Command);
+ Assert.Equal(3, stats[0].Sessions);
+ Assert.Equal("bash", stats[1].Command);
+ Assert.Equal(2, stats[1].Sessions);
+ Assert.Equal("pwsh", stats[2].Command);
+ Assert.Equal(1, stats[2].Sessions);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/SessionRunnerTests.cs b/tests/CodeShellManager.Tests/SessionRunnerTests.cs
new file mode 100644
index 0000000..87e07fc
--- /dev/null
+++ b/tests/CodeShellManager.Tests/SessionRunnerTests.cs
@@ -0,0 +1,323 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CodeShellManager.Models;
+using CodeShellManager.Services;
+using CodeShellManager.Terminal;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// Lifecycle tests for and using a
+/// hand-rolled . The PTY interface seam lives at
+/// — production code constructs a real ConPTY wrapper,
+/// tests inject a fake that lets us simulate output bytes and exit codes synchronously.
+///
+public class SessionRunnerTests
+{
+ private sealed class FakePseudoTerminal : IPseudoTerminal
+ {
+ public bool StartCalled { get; private set; }
+ public bool DisposeCalled { get; private set; }
+ public string? StartCommand { get; private set; }
+ public string? StartArgs { get; private set; }
+ public string? StartWorkingDirectory { get; private set; }
+
+ public int? ExitCode { get; private set; }
+
+ public event Action? DataReceived;
+ public event Action? Exited;
+
+ public void Start(string command, string args, string workingDirectory,
+ int cols = 220, int rows = 50, bool useJobObject = false)
+ {
+ StartCalled = true;
+ StartCommand = command;
+ StartArgs = args;
+ StartWorkingDirectory = workingDirectory;
+ }
+
+ public void EmitData(string text) => DataReceived?.Invoke(text);
+
+ public void EmitExit(int code)
+ {
+ ExitCode = code;
+ Exited?.Invoke();
+ }
+
+ public void Dispose() => DisposeCalled = true;
+ }
+
+ private static ShellSession LocalSession() => new()
+ {
+ WorkingFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ };
+
+ private static RunCommandItem Item(string id = "item-1", string label = "Build", string cmd = "dotnet build")
+ => new() { Id = id, Label = label, CommandLine = cmd };
+
+ // ── SessionRunner lifecycle ──────────────────────────────────────────────
+
+ [Fact]
+ public void Run_CreatesInstanceAndFiresInstancesChanged()
+ {
+ var fake = new FakePseudoTerminal();
+ var runner = new SessionRunner(LocalSession(), () => fake);
+ int fired = 0;
+ runner.InstancesChanged += () => fired++;
+
+ var inst = runner.Run(Item());
+
+ Assert.NotNull(inst);
+ Assert.True(fake.StartCalled);
+ Assert.Single(runner.Instances);
+ Assert.Same(inst, runner.Instances["item-1"]);
+ Assert.True(fired >= 1, $"InstancesChanged should fire on Run; fired={fired}");
+ }
+
+ [Fact]
+ public void Run_TwiceOnSameItem_DisposesPriorAndReplaces()
+ {
+ var fakes = new Queue(new[] { new FakePseudoTerminal(), new FakePseudoTerminal() });
+ var runner = new SessionRunner(LocalSession(), () => fakes.Dequeue());
+
+ var first = runner.Run(Item());
+ var firstFake = (FakePseudoTerminal)GetPty(first)!;
+ Assert.False(firstFake.DisposeCalled);
+
+ var second = runner.Run(Item());
+ var secondFake = (FakePseudoTerminal)GetPty(second)!;
+
+ Assert.True(firstFake.DisposeCalled, "First instance's PTY should be disposed when re-Run replaces it.");
+ Assert.False(secondFake.DisposeCalled);
+ Assert.NotSame(first, second);
+ Assert.Single(runner.Instances);
+ Assert.Same(second, runner.Instances["item-1"]);
+ }
+
+ [Fact]
+ public void Stop_DisposesInstanceAndFiresInstancesChanged()
+ {
+ var fake = new FakePseudoTerminal();
+ var runner = new SessionRunner(LocalSession(), () => fake);
+ runner.Run(Item());
+
+ int firedAfterRun = 0;
+ runner.InstancesChanged += () => firedAfterRun++;
+
+ runner.Stop("item-1");
+
+ Assert.True(fake.DisposeCalled);
+ Assert.True(firedAfterRun >= 1, $"InstancesChanged should fire on Stop; fired={firedAfterRun}");
+ // Stop keeps the instance around so the chip can still show failure state.
+ Assert.Single(runner.Instances);
+ }
+
+ [Fact]
+ public void Stop_UnknownItem_IsNoOp()
+ {
+ var runner = new SessionRunner(LocalSession(), () => new FakePseudoTerminal());
+ int fired = 0;
+ runner.InstancesChanged += () => fired++;
+
+ runner.Stop("does-not-exist");
+
+ Assert.Equal(0, fired);
+ Assert.Empty(runner.Instances);
+ }
+
+ [Fact]
+ public void Dismiss_RemovesInstanceFromDictionary()
+ {
+ var fake = new FakePseudoTerminal();
+ var runner = new SessionRunner(LocalSession(), () => fake);
+ runner.Run(Item());
+ Assert.Single(runner.Instances);
+
+ int fired = 0;
+ runner.InstancesChanged += () => fired++;
+
+ runner.Dismiss("item-1");
+
+ Assert.Empty(runner.Instances);
+ Assert.True(fake.DisposeCalled);
+ Assert.True(fired >= 1, $"InstancesChanged should fire on Dismiss; fired={fired}");
+ }
+
+ [Fact]
+ public void Dispose_StopsEveryRunningInstance()
+ {
+ var fakes = new List();
+ var runner = new SessionRunner(LocalSession(), () =>
+ {
+ var f = new FakePseudoTerminal();
+ fakes.Add(f);
+ return f;
+ });
+
+ runner.Run(Item(id: "a", cmd: "echo a"));
+ runner.Run(Item(id: "b", cmd: "echo b"));
+ runner.Run(Item(id: "c", cmd: "echo c"));
+ Assert.Equal(3, fakes.Count);
+
+ runner.Dispose();
+
+ Assert.All(fakes, f => Assert.True(f.DisposeCalled));
+ Assert.Empty(runner.Instances);
+ }
+
+ [Fact]
+ public void StopAll_StopsEveryRunningInstance()
+ {
+ var fakes = new List();
+ var runner = new SessionRunner(LocalSession(), () =>
+ {
+ var f = new FakePseudoTerminal();
+ fakes.Add(f);
+ return f;
+ });
+ runner.Run(Item(id: "a"));
+ runner.Run(Item(id: "b"));
+
+ runner.StopAll();
+
+ Assert.All(fakes, f => Assert.True(f.DisposeCalled));
+ Assert.Empty(runner.Instances);
+ }
+
+ // ── RunInstance state transitions ───────────────────────────────────────
+
+ [Fact]
+ public void RunInstance_Start_TransitionsToRunning()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+
+ inst.Start(LocalSession());
+
+ Assert.Equal(RunState.Running, inst.State);
+ Assert.True(fake.StartCalled);
+ Assert.NotNull(inst.StartedAt);
+ Assert.Null(inst.EndedAt);
+ }
+
+ [Fact]
+ public void RunInstance_ExitZero_TransitionsToExitedOk()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ int stateChanges = 0;
+ inst.StateChanged += () => stateChanges++;
+
+ fake.EmitExit(0);
+
+ Assert.Equal(RunState.ExitedOk, inst.State);
+ Assert.Equal(0, inst.ExitCode);
+ Assert.NotNull(inst.EndedAt);
+ Assert.True(stateChanges >= 1);
+ }
+
+ [Fact]
+ public void RunInstance_ExitNonZero_TransitionsToExitedFailed()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ fake.EmitExit(1);
+
+ Assert.Equal(RunState.ExitedFailed, inst.State);
+ Assert.Equal(1, inst.ExitCode);
+ Assert.NotNull(inst.EndedAt);
+ }
+
+ [Fact]
+ public void RunInstance_ExitNegative_TransitionsToExitedFailed()
+ {
+ // Windows exit codes can be negative (e.g. STATUS_CONTROL_C_EXIT = unchecked((int)0xC000013A)).
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ fake.EmitExit(unchecked((int)0xC000013A));
+
+ Assert.Equal(RunState.ExitedFailed, inst.State);
+ }
+
+ [Fact]
+ public void RunInstance_DisposeWhileRunning_ForcesExitedFailed()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+ Assert.Equal(RunState.Running, inst.State);
+
+ inst.Dispose();
+
+ Assert.True(fake.DisposeCalled);
+ Assert.Equal(RunState.ExitedFailed, inst.State);
+ Assert.NotNull(inst.EndedAt);
+ }
+
+ // ── Output buffer ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RunInstance_OutputBufferAppendsOnPtyData()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ int outputEvents = 0;
+ inst.OutputChanged += () => outputEvents++;
+
+ fake.EmitData("hello ");
+ fake.EmitData("world\n");
+
+ Assert.Equal("hello world\n", inst.SnapshotOutput());
+ Assert.True(outputEvents >= 2);
+ }
+
+ [Fact]
+ public void RunInstance_OutputBufferStripsAnsiEscapeSequences()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ // ESC[31m red ESC[0m → "red "
+ fake.EmitData("\x1B[31mred\x1B[0m text");
+
+ Assert.Equal("red text", inst.SnapshotOutput());
+ }
+
+ [Fact]
+ public void RunInstance_OutputBufferCapsAtOneMegabyte()
+ {
+ var fake = new FakePseudoTerminal();
+ var inst = new RunInstance(Item(), () => fake);
+ inst.Start(LocalSession());
+
+ // Push 1.5MB of plain ASCII (no ANSI, no \r — both get stripped).
+ // The buffer is documented to cap at 1MB (1_000_000 chars) and drop from the head.
+ const int chunkSize = 100_000;
+ string chunk = new('x', chunkSize);
+ for (int i = 0; i < 15; i++) fake.EmitData(chunk);
+
+ string snap = inst.SnapshotOutput();
+ Assert.Equal(1_000_000, snap.Length);
+ }
+
+ // ── Internal accessor for the private _pty field via reflection ──────────
+
+ private static IPseudoTerminal? GetPty(RunInstance inst)
+ {
+ var field = typeof(RunInstance).GetField(
+ "_pty",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ return (IPseudoTerminal?)field!.GetValue(inst);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs
new file mode 100644
index 0000000..8ff1315
--- /dev/null
+++ b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs
@@ -0,0 +1,93 @@
+using System.Text.Json;
+using CodeShellManager.Models;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+///
+/// State-file migration coverage. Legacy state.json predates the
+/// enum and only carried IsRemote; the deserializer must still produce a session
+/// with the right .
+///
+public class ShellSessionMigrationTests
+{
+ [Fact]
+ public void Deserialize_LegacyIsRemoteTrue_PromotesKindToSsh()
+ {
+ // Hand-rolled to match what an older app version would have written —
+ // no `Kind` key, only `IsRemote`.
+ const string legacy = """
+ {
+ "IsRemote": true,
+ "SshUser": "alice",
+ "SshHost": "dev.example.com",
+ "SshPort": 22
+ }
+ """;
+ var s = JsonSerializer.Deserialize(legacy)!;
+ Assert.Equal(SessionKind.Ssh, s.Kind);
+ Assert.True(s.IsRemote);
+ Assert.Equal("alice", s.SshUser);
+ }
+
+ [Fact]
+ public void Deserialize_LegacyIsRemoteFalse_KeepsKindLocal()
+ {
+ const string legacy = """{ "IsRemote": false, "WorkingFolder": "C:\\proj" }""";
+ var s = JsonSerializer.Deserialize(legacy)!;
+ Assert.Equal(SessionKind.Local, s.Kind);
+ Assert.False(s.IsRemote);
+ }
+
+ [Fact]
+ public void Deserialize_NewFormatWithKindWsl_LeavesIsRemoteFalse()
+ {
+ // StateService doesn't configure JsonStringEnumConverter, so enums round-trip
+ // as integers. SessionKind.Wsl == 2.
+ const string current = """
+ {
+ "Kind": 2,
+ "WslDistro": "Ubuntu",
+ "WslWorkingFolder": "/home/alice/proj"
+ }
+ """;
+ var s = JsonSerializer.Deserialize(current)!;
+ Assert.Equal(SessionKind.Wsl, s.Kind);
+ Assert.False(s.IsRemote);
+ Assert.Equal("Ubuntu", s.WslDistro);
+ }
+
+ [Fact]
+ public void Deserialize_BothKindAndLegacyIsRemote_KindWinsWhenKindIsWsl()
+ {
+ // Defensive: a file written by new code carries both IsRemote (computed, so false
+ // for Wsl) and Kind. Verify the setter never demotes a Wsl Kind back to Ssh.
+ const string mixed = """
+ {
+ "Kind": 2,
+ "IsRemote": false,
+ "WslDistro": "Ubuntu"
+ }
+ """;
+ var s = JsonSerializer.Deserialize(mixed)!;
+ Assert.Equal(SessionKind.Wsl, s.Kind);
+ }
+
+ [Fact]
+ public void Roundtrip_NewFormat_PreservesKind()
+ {
+ var original = new ShellSession
+ {
+ Kind = SessionKind.Wsl,
+ WslDistro = "Debian",
+ WslUser = "bob",
+ WslWorkingFolder = "/srv/app",
+ };
+ string json = JsonSerializer.Serialize(original);
+ var revived = JsonSerializer.Deserialize(json)!;
+ Assert.Equal(SessionKind.Wsl, revived.Kind);
+ Assert.Equal("Debian", revived.WslDistro);
+ Assert.Equal("bob", revived.WslUser);
+ Assert.Equal("/srv/app", revived.WslWorkingFolder);
+ }
+}
diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs
index d53872b..39bb35c 100644
--- a/tests/CodeShellManager.Tests/ShellSessionTests.cs
+++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs
@@ -90,4 +90,127 @@ public void BuildSshArgs_EmptyHost_ThrowsInvalidOperationException()
};
Assert.Throws(() => s.BuildSshArgs());
}
+
+ [Fact]
+ public void IsRemote_SetTrue_PromotesKindToSsh()
+ {
+ var s = new ShellSession { IsRemote = true };
+ Assert.Equal(SessionKind.Ssh, s.Kind);
+ Assert.True(s.IsRemote);
+ }
+
+ [Fact]
+ public void IsRemote_GetterTrueOnlyForSsh()
+ {
+ Assert.False(new ShellSession { Kind = SessionKind.Local }.IsRemote);
+ Assert.True(new ShellSession { Kind = SessionKind.Ssh }.IsRemote);
+ Assert.False(new ShellSession { Kind = SessionKind.Wsl }.IsRemote);
+ }
+
+ [Fact]
+ public void BuildWslArgs_HappyPath_BuildsExpectedShape()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslUser = "alice",
+ WslWorkingFolder = "/home/alice/proj", Command = "claude",
+ };
+ Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc \"claude\"",
+ s.BuildWslArgs());
+ }
+
+ [Fact]
+ public void BuildWslArgs_NoUser_OmitsUserFlag()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Debian",
+ WslWorkingFolder = "/srv", Command = "bash",
+ };
+ Assert.Equal("-d Debian --cd /srv -- bash -lc \"bash\"", s.BuildWslArgs());
+ }
+
+ [Fact]
+ public void BuildWslArgs_NoWorkingFolder_OmitsCdFlag()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu", Command = "bash",
+ };
+ Assert.Equal("-d Ubuntu -- bash -lc \"bash\"", s.BuildWslArgs());
+ }
+
+ [Fact]
+ public void BuildWslArgs_ArgsAppendedToShell()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu",
+ Command = "claude", Args = "--continue",
+ };
+ Assert.Contains("bash -lc \"claude --continue\"", s.BuildWslArgs());
+ }
+
+ [Fact]
+ public void BuildWslArgs_EmptyDistro_ThrowsInvalidOperationException()
+ {
+ var s = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "", Command = "bash" };
+ Assert.Throws(() => s.BuildWslArgs());
+ }
+
+ [Fact]
+ public void FullCommandLine_Wsl_StartsWithWslExe()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu",
+ Command = "claude",
+ };
+ Assert.StartsWith("wsl.exe ", s.FullCommandLine);
+ }
+
+ [Fact]
+ public void DefaultDisplayName_WslWithFolder_IsDistroAndLeaf()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu",
+ WslWorkingFolder = "/home/alice/proj",
+ };
+ Assert.Equal("Ubuntu: proj", s.DefaultDisplayName);
+ }
+
+ [Fact]
+ public void AccentKey_Wsl_DistinctFromLocal()
+ {
+ var wsl = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/proj",
+ };
+ var local = new ShellSession { WorkingFolder = "/proj" };
+ Assert.NotEqual(wsl.AccentKey, local.AccentKey);
+ }
+
+ [Theory]
+ [InlineData("Ubuntu", "Ubuntu")]
+ [InlineData("", "\"\"")]
+ [InlineData("/home/alice/proj", "/home/alice/proj")]
+ [InlineData("/home/alice/my proj", "\"/home/alice/my proj\"")]
+ [InlineData("with\"quote", "\"with\\\"quote\"")]
+ public void QuoteForCmd_QuotesWhenNeeded(string input, string expected)
+ {
+ Assert.Equal(expected, ShellSession.QuoteForCmd(input));
+ }
+
+ [Fact]
+ public void BuildWslArgs_LinuxPathWithSpaces_QuotesCdValue()
+ {
+ var s = new ShellSession
+ {
+ Kind = SessionKind.Wsl, WslDistro = "Ubuntu",
+ WslWorkingFolder = "/home/alice/my proj", Command = "claude",
+ };
+ Assert.Equal("-d Ubuntu --cd \"/home/alice/my proj\" -- bash -lc \"claude\"",
+ s.BuildWslArgs());
+ }
}
diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs
new file mode 100644
index 0000000..7421917
--- /dev/null
+++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs
@@ -0,0 +1,102 @@
+using System.Linq;
+using CodeShellManager.Services;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+public class WslDiscoveryServiceTests
+{
+ // Sample copied from `wsl -l -v` on a host with two distros installed. The
+ // leading whitespace in front of "NAME" and the spacing are intentional —
+ // wsl pads columns with spaces, never tabs.
+ private const string SampleOutput =
+ " NAME STATE VERSION\n" +
+ "* Ubuntu Running 2\n" +
+ " Debian Stopped 2\n";
+
+ [Fact]
+ public void Parse_TwoDistros_ReturnsBoth()
+ {
+ var result = WslDiscoveryService.Parse(SampleOutput);
+ Assert.Equal(2, result.Count);
+ }
+
+ [Fact]
+ public void Parse_MarksDefaultDistro()
+ {
+ var result = WslDiscoveryService.Parse(SampleOutput);
+ Assert.Single(result, d => d.IsDefault);
+ Assert.Equal("Ubuntu", result[0].Name); // default sorted first
+ }
+
+ [Fact]
+ public void Parse_ParsesVersionAndState()
+ {
+ var result = WslDiscoveryService.Parse(SampleOutput);
+ var ubuntu = result.Single(d => d.Name == "Ubuntu");
+ Assert.Equal(2, ubuntu.Version);
+ Assert.Equal("Running", ubuntu.State);
+ }
+
+ [Fact]
+ public void Parse_EmptyInput_ReturnsEmpty()
+ {
+ Assert.Empty(WslDiscoveryService.Parse(""));
+ Assert.Empty(WslDiscoveryService.Parse(" \n"));
+ }
+
+ [Fact]
+ public void Parse_HeaderOnly_ReturnsEmpty()
+ {
+ Assert.Empty(WslDiscoveryService.Parse(" NAME STATE VERSION\n"));
+ }
+
+ [Fact]
+ public void Parse_NonDefaultThenDefault_OrdersDefaultFirst()
+ {
+ const string reversed =
+ " NAME STATE VERSION\n" +
+ " Debian Stopped 2\n" +
+ "* Ubuntu Running 2\n";
+ var result = WslDiscoveryService.Parse(reversed);
+ Assert.Equal("Ubuntu", result[0].Name);
+ Assert.True(result[0].IsDefault);
+ }
+
+ [Fact]
+ public void ToUncPath_HappyPath()
+ {
+ Assert.Equal(@"\\wsl$\Ubuntu\home\alice\proj",
+ WslDiscoveryService.ToUncPath("Ubuntu", "/home/alice/proj"));
+ }
+
+ [Fact]
+ public void ToUncPath_NoLinuxPath_ReturnsDistroRoot()
+ {
+ Assert.Equal(@"\\wsl$\Ubuntu",
+ WslDiscoveryService.ToUncPath("Ubuntu", ""));
+ }
+
+ [Fact]
+ public void ToUncPath_NoDistro_ReturnsEmpty()
+ {
+ Assert.Equal("", WslDiscoveryService.ToUncPath("", "/home/x"));
+ }
+
+ [Fact]
+ public void Parse_DistroNameWithSpace_ParsesNameCorrectly()
+ {
+ // `wsl --import "My Distro" ...` produces a row where NAME spans two tokens.
+ // Old parser took just the first token; the from-the-end approach takes
+ // the trailing two columns as STATE/VERSION and joins the rest as NAME.
+ const string raw =
+ " NAME STATE VERSION\n" +
+ "* My Distro Running 2\n";
+ var result = WslDiscoveryService.Parse(raw);
+ Assert.Single(result);
+ Assert.Equal("My Distro", result[0].Name);
+ Assert.Equal("Running", result[0].State);
+ Assert.Equal(2, result[0].Version);
+ Assert.True(result[0].IsDefault);
+ }
+}