diff --git a/CLAUDE.md b/CLAUDE.md index 446d5a9..bf1b704 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,17 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj **Requirements:** .NET 10 SDK, Windows 10/11 (uses ConPTY + WebView2) +### Command-line flags + +| Flag | Effect | +|---|---| +| `--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. +- `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. + ## Architecture ### Key layers @@ -26,7 +37,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) ``` - **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only -- **TerminalBridge** (`Terminal/TerminalBridge.cs`): Routes bytes between PTY and xterm.js via WebView2 messages +- **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 @@ -55,7 +66,7 @@ src/CodeShellManager/ ├── MainWindow.xaml / .cs # Main UI (toolbar, sidebar, terminal grid) ├── Models/ │ ├── AppState.cs # AppSettings + AppState (JSON root) -│ ├── ShellSession.cs # Session data model (incl. SSH fields + BuildSshArgs) +│ ├── ShellSession.cs # Session data model (SSH fields, BuildSshArgs, IsDormant) │ ├── SessionGroup.cs # Group model │ └── AlertEvent.cs # Alert types: InputRequired, ToolApproval ├── Services/ @@ -97,13 +108,19 @@ tests/ **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. + ## Session Lifecycle 1. User clicks **+ New Session** → `NewSessionDialog` modal (Local or Remote SSH) 2. `SessionManager.CreateSession()` creates `ShellSession` model; caller copies SSH fields if remote 3. `LaunchSessionAsync()` creates: `SessionViewModel` → `WebView2` → `TerminalBridge` → `PseudoTerminal` 4. `OutputIndexer` indexes all output to SQLite; `AlertDetector` watches for prompts -5. On close: `Dispose()` chain cleans up PTY, bridge, indexer, detector +5. Termination paths: + - **Close** (`vm.CloseCommand`) → `MainViewModel.OnSessionCloseRequested` → `vm.Dispose()` + remove from `Sessions` + `SessionManager.RemoveSession()`. Session is gone from `state.json`. + - **Sleep** (`SleepSession(vm)`) → `vm.Dispose()` + remove from `Sessions` but **keep** the `ShellSession` in `SessionManager` with `IsDormant = true`. A muted dormant sidebar entry replaces the active one. + - **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true)` — same path as restore-on-startup. +6. On app close: `_vm.SaveStateAsync()` flushes `_sessionManager.Sessions` (live + dormant) to `state.json` (unless `--clean`). ## SSH Remote Sessions @@ -117,6 +134,22 @@ Remote sessions use the system `ssh` client as the PTY command — no extra libr - `SessionViewModel.RefreshGitInfoAsync()` early-returns for remote sessions (no local working folder) - SSH fields serialize to `state.json` automatically — sessions restore and relaunch on next startup +## 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. + +**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)` — sets `session.IsDormant = true`, removes from `_vm.Sessions` directly (bypassing `CloseCommand` so the `ShellSession` is **not** removed from `SessionManager`), disposes the VM, and calls `AddDormantSidebarItem(session)`. +- `WakeSessionAsync(session)` — clears `IsDormant`, removes the dormant sidebar entry, then `await 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 to `WakeSessionAsync`. +- Dormant entries are tracked in `_dormantSidebarItems: Dictionary` so `RebuildSidebarOrder` (called after drag-reorder) can re-append them at the bottom. +- `OnLoaded` partitions saved sessions: dormant ones go through `AddDormantSidebarItem`; live ones through `LaunchSessionAsync`. +- The empty-state placeholder hides whenever `_vm.Sessions.Count > 0` **or** `_dormantSidebarItems.Count > 0`. + ## Alert / Waiting State `AlertDetector` fires `AlertRaised(AlertEvent)` after 1.5s idle when it detects: @@ -140,12 +173,15 @@ Remote sessions use the system `ssh` client as the PTY command — no extra libr Persisted in `state.json`. Key settings: - `AutoRestoreSessions` — restore open sessions on next launch +- `AutoResumeClaude` — when restoring, append `--resume ` to claude commands so the prior conversation is picked up. Toggle off if you want fresh sessions on restart. - `ShowGitBranch` — show `⎇ branch` in sidebar - `ShowTerminalStatusDot` — show status dot in terminal toolbar - `SearchCollapseAfterNavigate` — auto-close search after clicking result - `MaxSearchResults` — 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. + ## Keyboard Shortcuts | Key | Action | @@ -182,7 +218,9 @@ The tag value overrides the csproj `` at publish time (`-p:Version=` fl ## Known Conventions - 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`) — not in XAML templates, to keep imperative logic centralized -- `_sessionUi` dictionary maps `sessionId → (webView, terminalWrapper, sidebarItem)` — the source of truth for all session UI references +- Sidebar items and terminal wrappers are built entirely in code-behind (`BuildSidebarItem`, `BuildTerminalWrapper`, `BuildDormantSidebarItem`) — not in XAML templates, to keep imperative logic centralized +- `_sessionUi` dictionary maps `sessionId → (webView, terminalWrapper, sidebarItem)` — the source of truth for live session UI. `_dormantSidebarItems` (`sessionId → Border`) tracks the parallel set for sleeping sessions. +- The `terminalWrapper` returned by `BuildTerminalWrapper` is actually the **outer active-ring Border**, with the original accent-stripe wrapper nested inside. `_sessionUi[id].terminalWrapper` therefore points at the ring; the highlight method toggles its `BorderBrush`. - Use `Dispatcher.Invoke()` for all UI updates from background threads (PTY read loop, git queries, alert timer) - PTY output flows: `PseudoTerminal` → `TerminalBridge.RawOutputReceived` → both `OutputIndexer.Feed()` and `AlertDetector.Feed()` in parallel +- `MainViewModel.SaveStateAsync` is a no-op when `App.CleanStart` is true; any code path that needs to "remember" something across runs must go through this method, so honoring `--clean` is automatic. diff --git a/README.md b/README.md index 7346893..95c09d1 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ 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) +- **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 +- **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. - **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 - **Git status** — shows branch and dirty state in the sidebar per session - **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 `) +- **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 - **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 @@ -53,6 +55,12 @@ cd CodeShellManager dotnet run --project src/CodeShellManager/CodeShellManager.csproj ``` +### Command-line flags + +| Flag | Effect | +|------|--------| +| `--clean` | Start with no preloaded sessions and skip writing `state.json` for the run. Useful when developing — your saved sessions/settings are left untouched. | + ## Keyboard Shortcuts | Key | Action | diff --git a/src/CodeShellManager/App.xaml.cs b/src/CodeShellManager/App.xaml.cs index 68b2c57..85b5b2c 100644 --- a/src/CodeShellManager/App.xaml.cs +++ b/src/CodeShellManager/App.xaml.cs @@ -1,9 +1,18 @@ +using System.Linq; + namespace CodeShellManager; public partial class App : System.Windows.Application { public static System.Windows.Forms.NotifyIcon? TrayIcon { get; private set; } + /// + /// When true (set by passing --clean on the command line), the app starts + /// with no preloaded sessions and persists no state changes for this run. Useful + /// for debugging: prior state.json contents are left untouched. + /// + public static bool CleanStart { get; private set; } + public static string LogPath { get; } = System.IO.Path.Combine( System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), "CodeShellManager", "crash.log"); @@ -12,6 +21,9 @@ protected override void OnStartup(System.Windows.StartupEventArgs e) { base.OnStartup(e); + CleanStart = e.Args.Any(a => + string.Equals(a, "--clean", System.StringComparison.OrdinalIgnoreCase)); + // Catch unhandled exceptions and write to log DispatcherUnhandledException += (_, ex) => { diff --git a/src/CodeShellManager/Assets/terminal.html b/src/CodeShellManager/Assets/terminal.html index 0a6d9b9..08f7a6a 100644 --- a/src/CodeShellManager/Assets/terminal.html +++ b/src/CodeShellManager/Assets/terminal.html @@ -83,6 +83,7 @@ else if (msg.type === 'clear') term.clear(); else if (msg.type === 'focus') { term.focus(); fitAddon.fit(); } else if (msg.type === 'fit') { fitAddon.fit(); term.focus(); } + else if (msg.type === 'paste') term.paste(msg.data); else if (msg.type === 'setOptions') { const opts = msg.options; if (opts.fontFamily !== undefined) term.options.fontFamily = opts.fontFamily; diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 5e03d61..cb8bf39 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -43,6 +43,9 @@ public partial class MainWindow : Window // Per-session UI: the WebView2, its persistent wrapper Border (built once, reused across layouts), // and its sidebar item. private readonly Dictionary _sessionUi = []; + // Sidebar items for dormant (asleep) sessions — kept here so RebuildSidebarOrder + // can re-append them to the bottom of the list after rebuilding active items. + private readonly Dictionary _dormantSidebarItems = []; private SqliteConnection? _db; private SearchService? _searchService; @@ -67,10 +70,19 @@ public MainWindow() RefreshTerminalLayout(); UpdateSidebarActiveState(); } + else if (args.PropertyName == nameof(MainViewModel.Layout)) + { + // Sync local layout field (used by RefreshTerminalLayout) with VM-driven changes + // — fires both for state-restore at startup and any future programmatic changes. + _currentLayout = _vm.Layout; + _layoutViewportOffset = 0; + RefreshTerminalLayout(); + } }; Loaded += OnLoaded; KeyDown += OnKeyDown; + Activated += OnWindowActivated; // Window state persistence: debounce position/size changes _windowStateTimer = new System.Windows.Threading.DispatcherTimer @@ -121,7 +133,16 @@ private async void OnLoaded(object sender, RoutedEventArgs e) _ = CheckForUpdatesAsync(); // fire-and-forget; never blocks startup var saved = _sessionManager.Sessions.ToList(); - Log($"OnLoaded: {saved.Count} saved sessions, AutoRestore={_vm.Settings.AutoRestoreSessions}"); + 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. + foreach (var s in saved) + _sessionManager.RemoveSession(s.Id); + return; + } if (saved.Count == 0) return; bool doRestore; @@ -141,6 +162,11 @@ private async void OnLoaded(object sender, RoutedEventArgs e) { foreach (var s in saved) { + if (s.IsDormant) + { + AddDormantSidebarItem(s); + continue; + } try { await LaunchSessionAsync(s, restoring: true); } catch (Exception ex) { @@ -282,6 +308,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal // Create bridge and initialize var bridge = new TerminalBridge(webView); vm.Bridge = bridge; + bridge.AcceleratorKeyPressed += OnBridgeAcceleratorKey; // Wire output indexer and alert detector if (_db != null) @@ -347,7 +374,8 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); effectiveArgs = session.Args; - if (restoring && ClaudeSessionService.IsClaudeCommand(session.Command) + if (restoring && _vm.Settings.AutoResumeClaude + && ClaudeSessionService.IsClaudeCommand(session.Command) && !effectiveArgs.Contains("--resume") && !effectiveArgs.Contains("--continue")) { @@ -617,11 +645,13 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm) var exploreBtn = MakeMiniButton("📁", "Open in Explorer", () => vm.OpenInExplorerCommand.Execute(null)); var psBtn = MakeMiniButton(">_", "Open PowerShell here", () => LaunchPowerShellInFolder(vm.WorkingFolder, vm.GroupId)); var renameBtn = MakeMiniButton("✏", "Rename session", StartRename); + var sleepBtn = MakeMiniButton("💤", "Sleep session (keep it but stop the terminal)", () => SleepSession(vm)); var closeBtn = MakeMiniButton("✕", "Close session", () => vm.CloseCommand.Execute(null)); btnPanel.Children.Add(exploreBtn); btnPanel.Children.Add(psBtn); btnPanel.Children.Add(renameBtn); + btnPanel.Children.Add(sleepBtn); btnPanel.Children.Add(closeBtn); Grid.SetColumn(textPanel, 1); @@ -751,6 +781,25 @@ private void UpdateSidebarActiveState() ? new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44)) : Brushes.Transparent; } + UpdateActiveTerminalHighlight(); + } + + private void UpdateActiveTerminalHighlight() + { + string? activeId = _vm.ActiveSession?.Id; + foreach (var (id, ui) in _sessionUi) + { + if (ui.terminalWrapper.Tag is not string accentHex) continue; + if (id == activeId) + { + var accent = (Color)ColorConverter.ConvertFromString(accentHex); + ui.terminalWrapper.BorderBrush = new SolidColorBrush(accent); + } + else + { + ui.terminalWrapper.BorderBrush = Brushes.Transparent; + } + } } // ── Sidebar drag-and-drop ───────────────────────────────────────────────── @@ -795,6 +844,9 @@ private void RebuildSidebarOrder() if (_sessionUi.TryGetValue(vm.Id, out var ui)) SidebarSessionList.Children.Add(ui.sidebarItem); } + // Dormant entries always render at the bottom of the sidebar. + foreach (var item in _dormantSidebarItems.Values) + SidebarSessionList.Children.Add(item); UpdateSidebarActiveState(); RefreshTerminalLayout(); } @@ -993,6 +1045,15 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) Background = new SolidColorBrush(Color.FromRgb(30, 30, 46)) }; + // Outer "active ring" — constant 2px frame; transparent unless this session is active + // (constant thickness avoids layout shift when activating/deactivating) + var activeRing = new Border + { + BorderThickness = new Thickness(2), + BorderBrush = Brushes.Transparent, + Tag = accent + }; + var outer = new DockPanel(); // Terminal toolbar @@ -1097,16 +1158,33 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) Margin = new Thickness(0, 0, 4, 0) }; + // Sleep (dormant) button — keeps the session in the sidebar but stops the PTY + var sleepBtn = new WpfButton + { + Content = "💤", + ToolTip = "Sleep session (keep it but stop the terminal)", + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xad, 0xc8)), + FontSize = 12, + Cursor = System.Windows.Input.Cursors.Hand, + Padding = new Thickness(4, 2, 4, 2), + Margin = new Thickness(0, 0, 4, 0) + }; + sleepBtn.Click += (_, _) => SleepSession(vm); + DockPanel.SetDock(termStatusDot, Dock.Right); DockPanel.SetDock(explorerBtn, Dock.Right); DockPanel.SetDock(toolbarPsBtn, Dock.Right); DockPanel.SetDock(notesBtn, Dock.Right); + DockPanel.SetDock(sleepBtn, Dock.Right); DockPanel.SetDock(claudeBadge, Dock.Left); DockPanel.SetDock(titleBlock, Dock.Left); toolbarContent.Children.Add(termStatusDot); toolbarContent.Children.Add(explorerBtn); toolbarContent.Children.Add(toolbarPsBtn); toolbarContent.Children.Add(notesBtn); + toolbarContent.Children.Add(sleepBtn); toolbarContent.Children.Add(claudeBadge); toolbarContent.Children.Add(titleBlock); toolbarContent.Children.Add(folderBlock); @@ -1176,6 +1254,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) outer.Children.Add(notesPanel); outer.Children.Add(webView); wrapper.Child = outer; + activeRing.Child = wrapper; // Subscribe to waiting state changes for the status dot vm.PropertyChanged += (_, args) => @@ -1204,7 +1283,7 @@ or nameof(SessionViewModel.IsWaitingForApproval)) } }; - return wrapper; + return activeRing; } // ── Session close ───────────────────────────────────────────────────────── @@ -1220,10 +1299,228 @@ private void OnSessionVmClosed(SessionViewModel vm) RefreshTerminalLayout(); UpdateAlertBadge(); - if (_vm.Sessions.Count == 0) + if (_vm.Sessions.Count == 0 && _dormantSidebarItems.Count == 0) EmptyState.Visibility = Visibility.Visible; } + // ── Sleep / wake (dormant sessions) ─────────────────────────────────────── + + /// + /// Sleeps a session: tears down the live PTY/terminal but keeps the session + /// definition in state so it can be woken later. Replaces the active sidebar + /// item with a muted "dormant" entry at the bottom of the list. + /// + private void SleepSession(SessionViewModel vm) + { + var session = vm.Session; + session.IsDormant = true; + + if (_sessionUi.TryGetValue(vm.Id, out var ui)) + { + if (TerminalGrid.Children.Contains(ui.terminalWrapper)) + TerminalGrid.Children.Remove(ui.terminalWrapper); + SidebarSessionList.Children.Remove(ui.sidebarItem); + _sessionUi.Remove(vm.Id); + } + + // Remove the VM directly — bypass CloseRequested so the ShellSession is + // NOT removed from the SessionManager (we want to keep it for wake-up). + _vm.Sessions.Remove(vm); + if (_vm.ActiveSession == vm) + _vm.ActiveSession = _vm.Sessions.LastOrDefault(); + vm.Dispose(); + + AddDormantSidebarItem(session); + + RefreshTerminalLayout(); + UpdateAlertBadge(); + EmptyState.Visibility = _vm.Sessions.Count == 0 && _dormantSidebarItems.Count == 0 + ? Visibility.Visible : Visibility.Collapsed; + + _ = _vm.SaveStateAsync(); + } + + /// + /// Wakes a dormant session: removes the dormant sidebar entry and relaunches + /// the session via LaunchSessionAsync (same path as restore-on-startup). + /// + private async Task WakeSessionAsync(ShellSession session) + { + session.IsDormant = false; + if (_dormantSidebarItems.TryGetValue(session.Id, out var dormantItem)) + { + SidebarSessionList.Children.Remove(dormantItem); + _dormantSidebarItems.Remove(session.Id); + } + + try + { + await LaunchSessionAsync(session, restoring: true); + } + catch (Exception ex) + { + Log($"Wake FAILED for '{session.Name}': {ex}"); + // Restore the dormant entry so the user doesn't lose access to the session + session.IsDormant = true; + AddDormantSidebarItem(session); + MessageBox.Show($"Failed to wake '{session.Name}': {ex.Message}", + "Wake Error", MessageBoxButton.OK, MessageBoxImage.Warning); + } + _ = _vm.SaveStateAsync(); + } + + private void AddDormantSidebarItem(ShellSession session) + { + var item = BuildDormantSidebarItem(session); + _dormantSidebarItems[session.Id] = item; + SidebarSessionList.Children.Add(item); + EmptyState.Visibility = Visibility.Collapsed; + } + + private Border BuildDormantSidebarItem(ShellSession session) + { + string accentHex = GetAccentForSession(session); + var accentColor = (Color)ColorConverter.ConvertFromString(accentHex); + + var container = new Border + { + Margin = new Thickness(0, 2, 0, 2), + Background = Brushes.Transparent, + Cursor = System.Windows.Input.Cursors.Hand, + CornerRadius = new CornerRadius(6), + Tag = "dormant:" + session.Id, + Opacity = 0.55, + ToolTip = "Click to wake this session" + }; + + var inner = new Grid(); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(6) }); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var stripe = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x60, accentColor.R, accentColor.G, accentColor.B)), + CornerRadius = new CornerRadius(4, 0, 0, 4), + Width = 6 + }; + Grid.SetColumn(stripe, 0); + + 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.Name; + + var nameText = new TextBlock + { + Text = displayName, + Foreground = new SolidColorBrush(Color.FromRgb(0x93, 0x99, 0xb2)), + FontSize = 13, + FontStyle = FontStyles.Italic, + TextTrimming = TextTrimming.CharacterEllipsis + }; + + string folderShort = session.IsRemote + ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost) + : (string.IsNullOrEmpty(session.WorkingFolder) + ? "" + : new System.IO.DirectoryInfo(session.WorkingFolder).Name); + + var folderText = new TextBlock + { + Text = folderShort, + Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)), + FontSize = 10, + Margin = new Thickness(0, 1, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + + textPanel.Children.Add(nameText); + textPanel.Children.Add(folderText); + Grid.SetColumn(textPanel, 1); + + // Sleep icon + var sleepIcon = new TextBlock + { + Text = "💤", + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 4, 0), + Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)) + }; + Grid.SetColumn(sleepIcon, 2); + + // Permanent-delete button — only way to fully remove a dormant session + var deleteBtn = MakeMiniButton("✕", "Delete this dormant session", () => + { + var result = MessageBox.Show( + $"Permanently delete dormant session '{displayName}'?", + "Delete session", MessageBoxButton.YesNo, MessageBoxImage.Question, + MessageBoxResult.No); + if (result != MessageBoxResult.Yes) return; + SidebarSessionList.Children.Remove(container); + _dormantSidebarItems.Remove(session.Id); + _sessionManager.RemoveSession(session.Id); + if (_vm.Sessions.Count == 0 && _dormantSidebarItems.Count == 0) + EmptyState.Visibility = Visibility.Visible; + _ = _vm.SaveStateAsync(); + }); + var btnPanel = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(0, 4, 4, 4), + VerticalAlignment = VerticalAlignment.Center + }; + btnPanel.Children.Add(deleteBtn); + Grid.SetColumn(btnPanel, 3); + + inner.Children.Add(stripe); + inner.Children.Add(textPanel); + inner.Children.Add(sleepIcon); + inner.Children.Add(btnPanel); + container.Child = inner; + + // Hover effect + container.MouseEnter += (_, _) => + { + container.Opacity = 0.85; + container.Background = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44)); + }; + container.MouseLeave += (_, _) => + { + container.Opacity = 0.55; + container.Background = Brushes.Transparent; + }; + + // Click anywhere on the row → wake + container.MouseLeftButtonDown += async (_, e) => + { + // Don't trigger wake when clicking the delete button + if (e.OriginalSource is System.Windows.DependencyObject dep + && IsDescendantOf(dep, btnPanel)) return; + await WakeSessionAsync(session); + }; + + return container; + } + + private static bool IsDescendantOf(System.Windows.DependencyObject node, System.Windows.DependencyObject ancestor) + { + for (var n = node; n != null; n = System.Windows.Media.VisualTreeHelper.GetParent(n)) + if (n == ancestor) return true; + return false; + } + + private static string GetAccentForSession(ShellSession s) => + s.ColorOverride ?? ColorService.GetHexColor( + s.IsRemote + ? (string.IsNullOrWhiteSpace(s.SshUser) ? s.SshHost : $"{s.SshUser}@{s.SshHost}") + : s.WorkingFolder); + // ── Search ──────────────────────────────────────────────────────────────── private void ToggleSearch_Click(object s, RoutedEventArgs e) @@ -1436,6 +1733,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) { var edited = dialog.EditedSettings; _vm.Settings.AutoRestoreSessions = edited.AutoRestoreSessions; + _vm.Settings.AutoResumeClaude = edited.AutoResumeClaude; _vm.Settings.ShowToastNotifications = edited.ShowToastNotifications; _vm.Settings.ShowNotificationSound = edited.ShowNotificationSound; _vm.Settings.AnthropicApiKey = edited.AnthropicApiKey; @@ -1537,6 +1835,7 @@ private async void Import_Click(object sender, RoutedEventArgs e) // Apply settings immediately var s = imported.Settings; _vm.Settings.AutoRestoreSessions = s.AutoRestoreSessions; + _vm.Settings.AutoResumeClaude = s.AutoResumeClaude; _vm.Settings.ShowToastNotifications = s.ShowToastNotifications; _vm.Settings.ShowNotificationSound = s.ShowNotificationSound; _vm.Settings.AnthropicApiKey = s.AnthropicApiKey; @@ -1569,26 +1868,35 @@ private async void Import_Click(object sender, RoutedEventArgs e) private void OnKeyDown(object s, WpfKeyEventArgs e) { - if (e.Key == Key.T && Keyboard.Modifiers == ModifierKeys.Control) - { - OpenNewSessionDialog(); - e.Handled = true; - } - else if (e.Key == Key.W && Keyboard.Modifiers == ModifierKeys.Control) - { - _vm.ActiveSession?.CloseCommand.Execute(null); + if (TryHandleGlobalShortcut(e.Key, Keyboard.Modifiers)) e.Handled = true; - } - else if (e.Key == Key.F && Keyboard.Modifiers == ModifierKeys.Control) - { - ToggleSearch_Click(s, new RoutedEventArgs()); - e.Handled = true; - } - else if (e.Key == Key.Tab && Keyboard.Modifiers == ModifierKeys.Control) - { - CycleSession(forward: true); + } + + // Invoked by TerminalBridge when a WebView2 accelerator key fires — routes + // the same shortcuts that OnKeyDown handles, so they work even when a + // terminal has focus (WebView2 otherwise swallows its own keys). + private void OnBridgeAcceleratorKey(object? sender, WpfKeyEventArgs e) + { + if (TryHandleGlobalShortcut(e.Key, Keyboard.Modifiers)) e.Handled = true; - } + } + + private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods) + { + if (key == Key.T && mods == ModifierKeys.Control) { OpenNewSessionDialog(); return true; } + if (key == Key.W && mods == ModifierKeys.Control) { _vm.ActiveSession?.CloseCommand.Execute(null); return true; } + if (key == Key.F && mods == ModifierKeys.Control) { ToggleSearch_Click(this, new RoutedEventArgs()); return true; } + if (key == Key.Tab && mods == ModifierKeys.Control) { CycleSession(forward: true); return true; } + if (key == Key.Tab && mods == (ModifierKeys.Control | ModifierKeys.Shift)) { CycleSession(forward: false); return true; } + return false; + } + + // Refocus the last-active terminal when the window regains focus (e.g. Alt+Tab). + // Without this, focus lands on whichever WPF control happened to hold it last — + // typically not the WebView2, so keystrokes go nowhere. + private void OnWindowActivated(object? sender, EventArgs e) + { + _vm.ActiveSession?.Bridge?.FocusTerminal(); } private void CycleSession(bool forward) diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index cef2dd3..759a40f 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -5,6 +5,7 @@ namespace CodeShellManager.Models; public class AppSettings { public bool AutoRestoreSessions { get; set; } = true; + public bool AutoResumeClaude { get; set; } = true; public bool ShowToastNotifications { get; set; } = false; public bool ShowNotificationSound { get; set; } = false; public string AnthropicApiKey { get; set; } = ""; diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 4565cfd..6fbac65 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -17,6 +17,12 @@ public class ShellSession public SessionStatus Status { get; set; } = SessionStatus.Idle; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + /// + /// When true, the session has no live PTY/terminal — it is a placeholder + /// in the sidebar that can be "woken" later. Persisted to state.json. + /// + public bool IsDormant { get; set; } + // SSH / remote session fields public bool IsRemote { get; set; } public string SshUser { get; set; } = ""; diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 36a1105..b00b577 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -6,6 +6,7 @@ using Microsoft.Web.WebView2.Wpf; using WpfApplication = System.Windows.Application; using WpfClipboard = System.Windows.Clipboard; +using WpfKeyEventArgs = System.Windows.Input.KeyEventArgs; namespace CodeShellManager.Terminal; @@ -29,6 +30,14 @@ public sealed class TerminalBridge : IDisposable public event Action? RawOutputReceived; public event Action? UserInput; + /// + /// Fires when the user presses a keyboard accelerator (Ctrl-combo, F-key, etc.) + /// while the WebView2 has focus. Subscribers set e.Handled = true to prevent + /// the key from also reaching xterm.js. The WPF WebView2 wrapper forwards + /// accelerator keys through standard WPF PreviewKeyDown events. + /// + public event EventHandler? AcceleratorKeyPressed; + private static void Log(string msg) { try @@ -72,6 +81,11 @@ public async Task InitializeAsync(string htmlPath) _webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived; + // Surface accelerator keys (Ctrl-combos, etc.) to WPF so global shortcuts + // still work when a terminal has focus. The WPF WebView2 wrapper forwards + // accelerator presses through standard PreviewKeyDown events. + _webView.PreviewKeyDown += OnAcceleratorKeyPressed; + // Log JS console messages and process failures _webView.CoreWebView2.ProcessFailed += (_, e) => Log($"WebView2 ProcessFailed: {e.ProcessFailedKind}"); @@ -159,6 +173,11 @@ private void OnPtyData(string rawData) }); } + private void OnAcceleratorKeyPressed(object? sender, WpfKeyEventArgs e) + { + AcceleratorKeyPressed?.Invoke(this, e); + } + // ── xterm.js messages → PTY / clipboard ───────────────────────────────── private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e) @@ -187,14 +206,18 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived } case "getClipboard": - // xterm.js wants to paste — return clipboard text on UI thread + // xterm.js wants to paste — round-trip the text through term.paste() so + // bracketed paste mode (CSI ?2004h) is honored. Apps like Claude Code + // require the \e[200~ ... \e[201~ markers to treat multi-line input as + // a single paste rather than submitting on the first newline. WpfApplication.Current?.Dispatcher.Invoke(() => { - string text = WpfClipboard.ContainsText() - ? WpfClipboard.GetText() - : ""; - if (!string.IsNullOrEmpty(text)) - _pty?.Write(text); + if (!WpfClipboard.ContainsText()) return; + string text = WpfClipboard.GetText(); + if (string.IsNullOrEmpty(text)) return; + string pasteJson = JsonSerializer.Serialize(new { type = "paste", data = text }); + try { _webView.CoreWebView2?.PostWebMessageAsString(pasteJson); } + catch { } }); break; @@ -280,6 +303,8 @@ public void Dispose() if (_webView.CoreWebView2 != null) { _webView.CoreWebView2.WebMessageReceived -= OnWebMessageReceived; + try { _webView.PreviewKeyDown -= OnAcceleratorKeyPressed; } + catch { } // NavigationCompleted is a local handler that unsubscribes itself — no need to remove here } } diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index c5c70d8..2675f9a 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -45,6 +45,11 @@ public async Task LoadStateAsync() public async Task SaveStateAsync() { + // In --clean mode, never write state.json so the user's prior session list + // survives the debug run untouched. Settings/window/layout changes from a + // clean run are also discarded — that's the point of "clean". + if (App.CleanStart) return; + _sessionManager.PopulateState(_appState); _appState.LastLayout = Layout.ToString(); await _stateService.SaveAsync(_appState); diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml index b8bcf36..b4c629e 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -205,6 +205,9 @@ + diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml.cs b/src/CodeShellManager/Views/SettingsWindow.xaml.cs index 9eb163f..59b3a5d 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml.cs +++ b/src/CodeShellManager/Views/SettingsWindow.xaml.cs @@ -23,6 +23,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) _edited = new AppSettings { AutoRestoreSessions = current.AutoRestoreSessions, + AutoResumeClaude = current.AutoResumeClaude, ShowToastNotifications = current.ShowToastNotifications, ShowNotificationSound = current.ShowNotificationSound, AnthropicApiKey = current.AnthropicApiKey, @@ -46,6 +47,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) // Populate controls DefaultFolderBox.Text = _edited.DefaultWorkingFolder; AutoRestoreCheck.IsChecked = _edited.AutoRestoreSessions; + AutoResumeClaudeCheck.IsChecked = _edited.AutoResumeClaude; ShowToastCheck.IsChecked = _edited.ShowToastNotifications; ShowNotificationSoundCheck.IsChecked = _edited.ShowNotificationSound; ShowGitBranchCheck.IsChecked = _edited.ShowGitBranch; @@ -106,6 +108,7 @@ private void Save_Click(object sender, RoutedEventArgs e) { _edited.DefaultWorkingFolder = DefaultFolderBox.Text.Trim(); _edited.AutoRestoreSessions = AutoRestoreCheck.IsChecked == true; + _edited.AutoResumeClaude = AutoResumeClaudeCheck.IsChecked == true; _edited.ShowToastNotifications = ShowToastCheck.IsChecked == true; _edited.ShowNotificationSound = ShowNotificationSoundCheck.IsChecked == true; _edited.ShowGitBranch = ShowGitBranchCheck.IsChecked == true;