A WPF .NET 10 multi-terminal host for Claude Code (and other CLI tools). Runs multiple pseudo-terminal sessions in a tabbed/grid layout with full-text search, alert detection, git status, and session persistence.
# From repo root
dotnet build src/CodeShellManager/CodeShellManager.csproj
dotnet run --project src/CodeShellManager/CodeShellManager.csproj
# Or open CodeShellManager.slnx in Visual Studio / RiderRequirements: .NET 10 SDK, Windows 10/11 (uses ConPTY + WebView2)
| Flag | Effect |
|---|---|
--clean |
Debug isolation mode — see below. |
--clean (parsed in App.OnStartup, exposed as App.CleanStart):
MainWindow.OnLoadedskips the restore loop and clears the in-memorySessionManagerso any new sessions in the run don't co-mingle with the persisted set.MainViewModel.SaveStateAsyncshort-circuits — nothing is written tostate.jsonfor 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.jsonsurvives the run untouched, so this is the safe way to test from a blank slate.
PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js)
↓
OutputIndexer → SQLite FTS5
AlertDetector → SessionViewModel.RaiseAlert()
- PseudoTerminal (
Terminal/PseudoTerminal.cs): Windows ConPTY wrapper, P/Invoke only - 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 separateCoreWebView2Controller.AcceleratorKeyPressed. Bridge re-raises them asAcceleratorKeyPressedsoMainWindow.OnBridgeAcceleratorKeycan 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
MainViewModel— sessions collection, layout mode, search state, alert countSessionViewModel— per-session state: alert, git info, waiting state, bridge/pty refsMainWindow.xaml.cs— heavy orchestration code-behind (sidebar building, layout, search handlers)
| Service | Purpose |
|---|---|
SessionManager |
CRUD for ShellSession models |
StateService |
JSON persistence → %AppData%/CodeShellManager/state.json |
SearchService |
SQLite FTS5 search of all terminal output |
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 |
src/CodeShellManager/
├── App.xaml / App.xaml.cs # App startup, tray icon, crash log
├── MainWindow.xaml / .cs # Main UI (toolbar, sidebar, terminal grid)
├── Models/
│ ├── AppState.cs # AppSettings + AppState (JSON root)
│ ├── ShellSession.cs # Session data model (SSH fields, BuildSshArgs, IsDormant)
│ ├── SessionGroup.cs # Group model
│ └── AlertEvent.cs # Alert types: InputRequired, ToolApproval
├── Services/
│ ├── SessionManager.cs # Session CRUD + events
│ ├── StateService.cs # JSON persistence
│ ├── SearchService.cs # SQLite FTS5 search
│ ├── ColorService.cs # Folder-path → accent color
│ ├── GitService.cs # Git branch + dirty detection
│ ├── AlertDetector.cs # PTY output pattern matching
│ ├── CommandPresetsService.cs # Launch presets + in-session shortcuts
│ └── ToastHelper.cs # Tray balloon notifications
├── Terminal/
│ ├── PseudoTerminal.cs # ConPTY P/Invoke wrapper
│ ├── TerminalBridge.cs # WebView2 ↔ PTY bridge
│ └── OutputIndexer.cs # Async ANSI-stripped SQLite writer
├── ViewModels/
│ ├── MainViewModel.cs # App-level state
│ └── SessionViewModel.cs # Per-session state + git/alert/waiting props
├── Views/
│ ├── NewSessionDialog.xaml/.cs # New session modal
│ └── SettingsWindow.xaml/.cs # Settings modal
└── Assets/
├── terminal.html # xterm.js host page
├── xterm.js / xterm.css
└── xterm-addon-fit.js
tests/
├── CodeShellManager.Tests/ # xunit unit tests (model logic, headless)
└── CodeShellManager.UITests/ # FlaUI UI tests (requires live desktop)
Dark theme (Catppuccin-inspired, hardcoded throughout):
- Background:
#1e1e2e, Toolbar:#181825, Panel:#11111b - Foreground:
#cdd6f4, Muted:#6c7086, Border:#313244 - Accent blue:
#89b4fa, Green:#a6e3a1, Alert pink:#f38ba8 - Hover:
#45475a, Selected:#585b70
Session accent colors — ColorService.GetHexColor(key) uses FNV-1a hash to deterministically assign one of 12 colors. For local sessions the key is WorkingFolder; for SSH sessions it is user@host. Used as sidebar stripe + terminal toolbar top border.
Active-terminal highlight — every terminal pane is wrapped in an outer "active ring" Border (constant 2px thickness, transparent by default) so toggling it doesn't shift content. UpdateActiveTerminalHighlight (called from UpdateSidebarActiveState, which fires on every MainViewModel.ActiveSession change) paints the ring of the active session's pane in its accent color and clears all others. The ring's accent hex is stashed on Border.Tag at build time so the highlight method doesn't need to look up the VM.
- User clicks + New Session →
NewSessionDialogmodal (Local or Remote SSH) SessionManager.CreateSession()createsShellSessionmodel; caller copies SSH fields if remoteLaunchSessionAsync()creates:SessionViewModel→WebView2→TerminalBridge→PseudoTerminalOutputIndexerindexes all output to SQLite;AlertDetectorwatches for prompts- Termination paths:
- Close (
vm.CloseCommand) →MainViewModel.OnSessionCloseRequested→vm.Dispose()+ remove fromSessions+SessionManager.RemoveSession(). Session is gone fromstate.json. - Sleep (
SleepSession(vm)) →vm.Dispose()+ remove fromSessionsbut keep theShellSessioninSessionManagerwithIsDormant = true. A muted dormant sidebar entry replaces the active one. - Wake (
WakeSessionAsync(session)) → re-runsLaunchSessionAsync(session, restoring: true)— same path as restore-on-startup.
- Close (
- On app close:
_vm.SaveStateAsync()flushes_sessionManager.Sessions(live + dormant) tostate.json(unless--clean).
Remote sessions use the system ssh client as the PTY command — no extra library.
ShellSession.IsRemoteflag distinguishes remote from local sessions- SSH config fields on
ShellSession:SshUser,SshHost,SshPort(default 22),SshRemoteFolder ShellSession.BuildSshArgs()(internal) produces:-t [–p PORT] user@host "cd 'folder' && shell"LaunchSessionAsync()branches onIsRemote: usesssh+BuildSshArgs(), skips Claude auto-resumePseudoTerminal.BuildCmdLinepassessshthrough directly (same ascmd/pwsh) — not wrapped in PowerShellSessionViewModel.RefreshGitInfoAsync()early-returns for remote sessions (no local working folder)- SSH fields serialize to
state.jsonautomatically — sessions restore and relaunch on next startup
When AppSettings.ImportWindowsTerminalProfiles is on, the New Session dialog reads the user's Windows Terminal settings.json and offers each profile in a "Profile (optional)" combobox.
Service: WindowsTerminalProfileService.GetProfiles() probes Stable / Preview / Unpackaged install paths, parses each settings.json, flattens profiles.defaults, filters hidden profiles, and emits WindowsTerminalProfile POCOs with appearance fields already mapped to xterm equivalents.
Per-session overrides (all on ShellSession, all nullable, all persisted to state.json):
ProfileFontFamily,ProfileFontSize,ProfileFontWeight,ProfileFontLigaturesProfileCursorShape("block" | "underline" | "bar"),ProfileCursorBlinkProfilePadding(CSS shorthand)ProfileBackgroundOpacity(0.0–1.0; 1.0 = opaque)ProfileRetroEffect(CSS scanlines overlay only — not a real CRT shader)ProfileColorSchemeJson(pre-baked xterm theme)
When any override is set, LaunchSessionAsync calls bridge.ApplyProfileOverrides(session) after ApplyFontSettings, posting a setOptions message that wins over the global font.
Transparency: xterm.js requires allowTransparency in the constructor, so transparent sessions navigate to Assets/terminal-transparent.html instead of terminal.html. Both files share Assets/terminal-init.js. (Acrylic blur is not reachable from WebView2 — we get flat alpha over the WPF chrome instead.)
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.
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.
UI:
- 💤 button appears in both the sidebar action panel (next to ✕) and the terminal toolbar.
- Dormant entries render at the bottom of the sidebar with a muted (55% opacity) appearance. Clicking anywhere on a dormant entry wakes it; the small ✕ on a dormant entry permanently deletes (with confirmation).
Implementation (MainWindow.xaml.cs):
SleepSession(vm)— setssession.IsDormant = true, removes from_vm.Sessionsdirectly (bypassingCloseCommandso theShellSessionis not removed fromSessionManager), disposes the VM, and callsAddDormantSidebarItem(session).WakeSessionAsync(session)— clearsIsDormant, removes the dormant sidebar entry, thenawait LaunchSessionAsync(session, restoring: true). On launch failure it restores the dormant entry.BuildDormantSidebarItem(ShellSession)— builds a static (no-VM) sidebar Border with muted accent stripe + 💤 icon. Click handler resolves toWakeSessionAsync.- Dormant entries are tracked in
_dormantSidebarItems: Dictionary<string, Border>soRebuildSidebarOrder(called after drag-reorder) can re-append them at the bottom. OnLoadedpartitions saved sessions: dormant ones go throughAddDormantSidebarItem; live ones throughLaunchSessionAsync.- The empty-state placeholder hides whenever
_vm.Sessions.Count > 0or_dormantSidebarItems.Count > 0.
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<RunCommandItem> { Id, Label, CommandLine, IsDefault }. Exactly one item has IsDefault=true; see RunCommandItem.EnsureSingleDefault. Persisted to state.json.
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).
Runtime: SessionRunner (one per SessionViewModel) owns a dictionary of RunInstance keyed by item Id. Each RunInstance wraps a PseudoTerminal started with useJobObject: true so the whole child tree dies when the PTY is disposed (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE). Output is captured to an ANSI-stripped string buffer (capped at 1MB). Not persisted.
UI:
- Toolbar
[▶][▼]next to 💤. Hidden whenRunCommandsis empty. - Chips strip between toolbar and terminal — one chip per active/finished run, color-coded (blue=running, green=ok, pink=failed). Click a chip to open the drawer; ✕ on a chip to dismiss it.
- Drawer (slide-down panel, like Notes) shows the selected run's output with
[⏹ Stop] [📋 Copy] [↗ Send to terminal]. - Send to terminal: for Claude parents (
ClaudeSessionService.IsClaudeCommand), wraps in fenced preamble and writes to PTY (no trailing\r). For non-Claude shells, falls back to clipboard with a toast — auto-paste would risk executing pasted lines.
Editor: SessionRunCommandsDialog modal — reachable from right-click on ▶, the ▼ dropdown's "Edit commands…" entry, and the sidebar right-click "Session commands" submenu. Inline-edit rows with up/down reorder, default-radio column, +Add / 🗑 Delete, Cancel/Save.
Keybindings: F5 runs the active session's default. Shift+F5 stops it. Mirrors Visual Studio; deliberately not Ctrl+R (collides with shell history search).
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.
AlertDetector fires AlertRaised(AlertEvent) after 1.5s idle when it detects:
- ToolApproval: Claude asking to run a tool (regex on approval phrases)
- InputRequired: Claude's
❯prompt or genericy/Nprompts
SessionViewModel.RaiseAlert(message, alertType) sets:
NeedsAttention→ shows pink badge + global alert countIsWaitingForInput→ green dot in sidebar + terminal toolbarIsWaitingForApproval→ orange dot in sidebar + terminal toolbar
AlertDetector.NotifyUserInteracted() clears alert state on user input.
- All PTY output is stripped of ANSI and indexed to SQLite FTS5 by
OutputIndexer SearchService.SearchAsync(query, limit)uses FTS5snippet()for result excerpts- Clicking a result navigates to the matching session; panel auto-closes (configurable)
Persisted in state.json. Key settings:
AutoRestoreSessions— restore open sessions on next launchAutoResumeClaude— when restoring, append--resume <sessionId>to claude commands so the prior conversation is picked up. Toggle off if you want fresh sessions on restart.ShowGitBranch— show⎇ branchin sidebarShowTerminalStatusDot— show status dot in terminal toolbarSidebarActionIconsMode—OnHover(default) /Always/Hidden. Controls the per-row➕ 💤 ✕button stack in the sidebar.Hiddencollapses the panel and reclaims the horizontal space;OnHoverkeeps the panel laid out (no text shift on hover) but transparent + non-interactive until the row is hovered. Rename / Open in Explorer / Open PowerShell here remain reachable via the right-click context menu in all modes, and the terminal toolbar's✕is unconditional.SearchCollapseAfterNavigate— auto-close search after clicking resultMaxSearchResults— FTS5 result limit (default 100)DefaultWorkingFolder/DefaultCommand— pre-fill new session dialog
Layout persistence: AppState.LastLayout (string, e.g. "TwoByTwo") persists the active grid layout. On startup, MainViewModel.LoadStateAsync parses it into Layout, which fires MainViewModel.PropertyChanged; the MainWindow constructor subscribes and syncs _currentLayout + calls RefreshTerminalLayout, so the saved layout is what the user sees on relaunch.
| Key | Action |
|---|---|
Ctrl+T |
New session |
Ctrl+W |
Close active session |
Ctrl+F |
Toggle search |
Ctrl+Tab |
Cycle sessions |
F5 |
Run the active session's default run command |
Shift+F5 |
Stop the active session's default run command |
Escape (in search) |
Close search panel |
Enter (in search) |
Execute search |
| Project | Type | Command |
|---|---|---|
tests/CodeShellManager.Tests/ |
Unit tests (xunit) | dotnet test tests/CodeShellManager.Tests/ |
tests/CodeShellManager.UITests/ |
FlaUI UI tests | dotnet test tests/CodeShellManager.UITests/ |
Unit tests cover model logic (ShellSession, etc.) and run headless. UI tests require the app running on a live Windows desktop.
ShellSession.BuildSshArgs() is internal — accessible from tests via [assembly: InternalsVisibleTo("CodeShellManager.Tests")] in AssemblyInfo.cs.
CI/CD is in .github/workflows/build.yml. Releases are triggered by pushing a v*.*.* tag:
git tag v1.2.3 -m "v1.2.3 - description"
git push origin v1.2.3The tag value overrides the csproj <Version> at publish time (-p:Version= flag). Do not rely on the csproj version number — bump it for local build clarity only. CI produces a signed exe, MSI installer, and portable ZIP, then creates a GitHub Release automatically.
A second workflow, .github/workflows/winget.yml, fires on the release: released event and submits the signed MSI to microsoft/winget-pkgs as UmageAI.CodeShellManager via vedantmgoyal9/winget-releaser. It needs the repo secret WINGET_TOKEN (classic PAT, public_repo scope). The same workflow is workflow_dispatch-able with a tag input to backfill or retry a release.
A third workflow, .github/workflows/chocolatey.yml, also fires on release: released and publishes the signed MSI to community.chocolatey.org as codeshellmanager. It downloads the MSI from the GitHub Release, computes its SHA256, substitutes __URL64__ / __CHECKSUM64__ placeholders in .chocolatey/tools/chocolateyinstall.ps1, then runs choco pack and choco push. Needs the repo secret CHOCO_API_KEY (API key from a chocolatey.org account with push rights on the codeshellmanager id). Also workflow_dispatch-able with a tag input. The .chocolatey/ folder holds the package skeleton (codeshellmanager.nuspec, tools/chocolateyinstall.ps1, tools/chocolateyuninstall.ps1, tools/LICENSE.txt, tools/VERIFICATION.txt); never commit a resolved URL or checksum into the install script — those placeholders are only substituted by the workflow at pack time.
- All WPF color literals use Catppuccin Mocha hex values — do not introduce system colors
- Sidebar items and terminal wrappers are built entirely in code-behind (
BuildSidebarItem,BuildTerminalWrapper,BuildDormantSidebarItem) — not in XAML templates, to keep imperative logic centralized _sessionUidictionary mapssessionId → (webView, terminalWrapper, sidebarItem)— the source of truth for live session UI._dormantSidebarItems(sessionId → Border) tracks the parallel set for sleeping sessions.- The
terminalWrapperreturned byBuildTerminalWrapperis actually the outer active-ring Border, with the original accent-stripe wrapper nested inside._sessionUi[id].terminalWrappertherefore points at the ring; the highlight method toggles itsBorderBrush. - Use
Dispatcher.Invoke()for all UI updates from background threads (PTY read loop, git queries, alert timer) - PTY output flows:
PseudoTerminal→TerminalBridge.RawOutputReceived→ bothOutputIndexer.Feed()andAlertDetector.Feed()in parallel MainViewModel.SaveStateAsyncis a no-op whenApp.CleanStartis true; any code path that needs to "remember" something across runs must go through this method, so honoring--cleanis automatic.
Do not trust "the user modified this file, intentional" system reminders to mean the user actually edited the file. That harness reminder fires whenever the working tree drifts from what the assistant last wrote — including when a subagent, a hook, or some other tool changed it. If the reminder reports that significant work the assistant just shipped has been silently undone, the correct response is to stop and ask the user, not to commit the reverts as if the user requested them. Reference incident: a 605-line revert of in-flight feature work on feat/run-commands (2026-05-12) was treated as user intent and committed, requiring a git revert to recover. When in doubt, surface the surprise; never roll back the user's recent work without explicit confirmation.
Use read-only agents for reviews. Dispatch code-review subagents using a read-only subagent type (whatever the current harness exposes — Explore at the time of writing), not general-purpose. Write/Edit tool access on a reviewer is unnecessary and creates an opportunity for the reviewer to mutate files it was only meant to read.