Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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.

Expand Down Expand Up @@ -53,10 +53,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

Expand Down Expand Up @@ -155,6 +169,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.
Expand All @@ -175,7 +202,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<RunCommandItem> { Id, Label, CommandLine, IsDefault }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`.
**Data:** `ShellSession.RunCommands: List<RunCommandItem> { 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 <utf16le-b64>` (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).

Expand All @@ -193,7 +223,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)
Expand Down Expand Up @@ -231,6 +274,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 |
Expand Down
3 changes: 3 additions & 0 deletions installer/CodeShellManager.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
<Component Directory="AssetsFolder" Guid="*">
<File Source="$(var.PublishDir)\Assets\app.ico" KeyPath="yes" />
</Component>
<Component Directory="AssetsFolder" Guid="*">
<File Source="$(var.PublishDir)\Assets\app.png" KeyPath="yes" />
</Component>
Comment on lines +56 to +58
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f31faf: added <Content Include="Assets\app.png"> with CopyToOutputDirectory=PreserveNewest and ExcludeFromSingleFile=true to CodeShellManager.csproj, matching the pattern for app.ico and the other assets.


<!-- Assets — one component per file so auto-GUID works.
If you add a new file to src/CodeShellManager/Assets, also add it here
Expand Down
17 changes: 17 additions & 0 deletions src/CodeShellManager/Assets/terminal-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@
fitAddon.fit();
}
else if (msg.type === 'dropOverlayClear') overlay.classList.remove('active');
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 });
}
}
} catch {}
});

Expand Down
35 changes: 35 additions & 0 deletions src/CodeShellManager/Assets/terminal-transparent.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,46 @@
mix-blend-mode: multiply;
z-index: 50;
}

/* 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; }
</style>
<link rel="stylesheet" href="xterm.css" />
</head>
<body>
<div id="terminal"></div>
<div id="bootOverlay">
<svg id="bootSpinner" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20"/></svg>
<div id="bootLabel">Initializing terminal…</div>
</div>
<div id="dropOverlay">Drop files to insert path(s)</div>

<script src="xterm.js"></script>
Expand Down
35 changes: 35 additions & 0 deletions src/CodeShellManager/Assets/terminal.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,46 @@
mix-blend-mode: multiply;
z-index: 50;
}

/* 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; }
</style>
<link rel="stylesheet" href="xterm.css" />
</head>
<body>
<div id="terminal"></div>
<div id="bootOverlay">
<svg id="bootSpinner" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20"/></svg>
<div id="bootLabel">Initializing terminal…</div>
</div>
<div id="dropOverlay">Drop files to insert path(s)</div>

<script src="xterm.js"></script>
Expand Down
15 changes: 15 additions & 0 deletions src/CodeShellManager/CodeShellManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup>

<!-- Hot Reload (Microsoft.Extensions.DotNetDeltaApplier) crashes the app with
System.ExecutionEngineException on F5 under .NET 10.0.8 + VS 18. The primary
fix is "hotReloadEnabled": false in Properties/launchSettings.json which
prevents VS from injecting the delta applier at all. This runtime feature
switch is a belt-and-braces fallback for anyone whose launchSettings.json
has been overridden locally. Remove both when the runtime bug is fixed
upstream. -->
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
Expand Down Expand Up @@ -42,6 +53,10 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Include="Assets\app.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
<Content Include="Assets\terminal-init.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
Expand Down
31 changes: 31 additions & 0 deletions src/CodeShellManager/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
</Style>
</Window.Resources>

<Grid>
<DockPanel>
<!-- ── Top toolbar ─────────────────────────────────────────────── -->
<Border DockPanel.Dock="Top" Background="#181825" BorderThickness="0,0,0,1"
Expand Down Expand Up @@ -139,6 +140,8 @@
Click="Layout_SixTwo_Click" FontSize="9" Padding="4,4"/>
<Button Content="6×3" Style="{StaticResource ToolBtn}" ToolTip="6×3 grid (18 panes)"
Click="Layout_SixThree_Click" FontSize="9" Padding="4,4"/>
<Button Content="3×3" Style="{StaticResource ToolBtn}" ToolTip="3×3 grid (9 panes)"
Click="Layout_ThreeByThree_Click" FontSize="9" Padding="4,4"/>
</StackPanel>

<Button DockPanel.Dock="Left" AutomationProperties.AutomationId="NewSessionBtn" Content="+ New Session" Style="{StaticResource ToolBtn}"
Expand Down Expand Up @@ -288,4 +291,32 @@
LineHeight="24"/>
</Grid>
</DockPanel>

<Grid x:Name="ShutdownOverlay" Visibility="Collapsed" Background="#cc1e1e2e" Panel.ZIndex="100">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
<Grid Width="48" Height="48">
<Path Stroke="#89b4fa" StrokeThickness="4" StrokeStartLineCap="Round" StrokeEndLineCap="Round"
Data="M 24 4 A 20 20 0 1 1 4 24"
RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="ShutdownSpinnerRotate" Angle="0"/>
</Path.RenderTransform>
<Path.Triggers>
<EventTrigger RoutedEvent="Path.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="ShutdownSpinnerRotate"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Path.Triggers>
</Path>
</Grid>
<TextBlock Text="Shutting down…" Foreground="#cdd6f4" FontFamily="Segoe UI" FontSize="13"
Margin="0,14,0,0" HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Grid>
</Window>
Loading
Loading