diff --git a/CLAUDE.md b/CLAUDE.md index f1796b0..2c0f875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -909,6 +909,33 @@ OSC input through and forwards DA1, measured to turn Claude's probe result from Pre-seeding the reply before the program asks was rejected — in a non-probing program it would be stray keystrokes in the shell. +## Closing busy sessions (app close, Ctrl+W, ✕, Close menus) + +`OnClosing` first calls `ConfirmCloseWithBusySessions`: if any live session is busy, an owned +`Views/BusySessionsDialog` lists each one (accent stripe, name, why) and asks — **Keep running** +is the default for both Enter and Esc. An owned window rather than an overlay like the shutdown +board, because asking must not collapse `TerminalGrid` (HwndHost — see "Session Spinners"). + +"Busy" is the WPF-free `Services/SessionBusyRules` (tested in `SessionBusyRulesTests`): +- a session that speaks OSC 7501 is judged by its report only — `working` (+progress) and + `blocked` (mid-turn) are busy; idle/done/error are not, whatever it prints; +- any other session is busy if it printed within `RecentOutputWindow` (4s) — + `SessionViewModel.NoteOutput` stamps every PTY chunk (lock-free, any thread); +- a running run command is busy either way. + +**Closing sessions asks the same question.** Ctrl+W, the sidebar and toolbar ✕, the per-session +"Close (N)" menu and both "Close all…" items go through `RequestCloseSession` / +`RequestCloseSessions` → `ConfirmCloseBusy`, never straight to `vm.CloseCommand`. A "Close all…" +asks *one* question: the busy dialog when something is busy, else its old plain Yes/No. +Worktree removal calls `CloseCommand` directly — it has its own confirmation naming the sessions. +New UI close paths must use the `Request…` helpers. + +Skipped when `AppSettings.WarnOnCloseWhenBusy` is off (Settings, or the dialog's "Don't warn me +again"), and when Windows is logging off (`Application.SessionEnding`) — the OS would otherwise +show "this app is preventing shutdown" over it (app close only). `CloseWarningTests` (UI) runs +`ping -t` and checks the pane ✕ and both app-close answers; `AppActions.CloseFirstSession` +confirms the dialog when it appears, since a test session may still be printing. + ## 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. @@ -1338,6 +1365,7 @@ block the user and shutdown does). Persisted in `state.json`. Key settings: - `AutoRestoreSessions` — restore open sessions on next launch +- `WarnOnCloseWhenBusy` (default true) — ask before closing the app or a session while it is busy; see "Closing the app while sessions are busy". - `AutoResumeClaude` — when restoring, append `--resume ` to claude commands so the prior conversation is picked up. Toggle off if you want fresh sessions on restart. - `ClaudeLaunchStaggerMs` (default 2000) — flat delay between consecutive Claude launches, and the cap on the post-exit settle at shutdown. The Claude CLI rewrites its config unlocked on startup and exit, so two `claude.exe` doing it at once can lose each other's updates. Evidence this is real: a machine here had two orphaned `.claude.json.tmp..` files with the same timestamp and different pids. diff --git a/README.md b/README.md index fa7d901..5568ab0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi - **Restart sessions** — ↻ restarts a session in place (Claude conversations resume), or restart a whole group or every session to pick up a new CLI build; bulk restarts show progress and can be stopped - **Git worktrees** — start a new session in a fresh worktree from any session's branch; sibling worktrees can be grouped together in the sidebar. The git menu's **Worktrees ▸** lists every worktree of the repo with its state, jumps to (or opens) its session, prunes worktrees whose folder is gone, and removes a finished one — closing its sessions first, refusing if anything is uncommitted, and offering to delete the branch only if it is merged - **Edit session** — change a session's folder, command, SSH/WSL target or appearance after it was created +- **Close warning** — closing the app or a session (Ctrl+W, ✕, Close all…) while it is still busy (Claude working or waiting mid-task, output streaming, a run command running) lists them and asks first; "Keep running" is the default - **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 diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 5a1d0b8..a84483c 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -115,6 +115,10 @@ private enum SortField { None, Name, Folder, LastActive, Branch, Dirty, Repo } private bool _isShuttingDown = false; private bool _shutdownComplete = false; + // Windows is logging off / shutting down: never hold that up with the "still running?" + // question — the OS would show "this app is preventing shutdown" over it. + private bool _osSessionEnding = false; + // Guards RestartSessionsAsync so two restart loops can't interleave — see the comment // there for why restarts have to stay strictly sequential. private bool _restartInProgress = false; @@ -141,6 +145,8 @@ public MainWindow() LoadSavedWindowState(); // before Show — see MainWindow.Placement.cs _vm = new MainViewModel(_sessionManager, _stateService); _vm.SessionClosed += OnSessionVmClosed; + if (Application.Current != null) + Application.Current.SessionEnding += (_, _) => _osSessionEnding = true; // Refresh terminal layout whenever active session changes (Single mode needs this) _vm.PropertyChanged += (_, args) => @@ -1530,6 +1536,8 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { bridge.RawOutputReceived += alertDetector.Feed; } + // Feeds the "still busy?" check on app close (SessionBusyRules). + bridge.RawOutputReceived += _ => vm.NoteOutput(); // Shell programs (e.g. an SSH overlay, a prompt hook, a Claude Code hook) push // session state via OSC 9001. Apply it on the VM, then debounce-save so the @@ -1989,7 +1997,7 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm) var spawnBtn = MakeMiniButton("➕", "New session here (inherits group + profile)", () => OpenNewSessionDialogFromParent(vm)); var sleepBtn = MakeMiniButton("💤", "Sleep session (keep it but stop the terminal)", () => SleepSession(vm)); - var closeBtn = MakeMiniButton("✕", "Close session", () => vm.CloseCommand.Execute(null)); + var closeBtn = MakeMiniButton("✕", "Close session", () => RequestCloseSession(vm)); btnPanel.Children.Add(spawnBtn); btnPanel.Children.Add(sleepBtn); @@ -2574,13 +2582,10 @@ private System.Windows.Controls.ContextMenu BuildSidebarQuickMenu() closeAllGlobal.Click += (_, _) => { var targets = _vm.Sessions.ToList(); - if (targets.Count == 0) return; - var r = MessageBox.Show( + RequestCloseSessions(targets, () => MessageBox.Show( $"Close all {targets.Count} live session(s)? They will be removed permanently.", - "Close all", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); - if (r != MessageBoxResult.Yes) return; - foreach (var vm in targets) - vm.CloseCommand.Execute(null); + "Close all", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) + == MessageBoxResult.Yes); }; bulkActions.Items.Add(closeAllGlobal); menu.Items.Add(bulkActions); @@ -3268,13 +3273,10 @@ await RestartSessionsAsync(ids, closeAll.Click += (_, _) => { var targets = _vm.Sessions.Where(v => v.Session.GroupId == groupId).ToList(); - if (targets.Count == 0) return; - var r = MessageBox.Show( + RequestCloseSessions(targets, () => MessageBox.Show( $"Close {targets.Count} session(s) in group '{groupName}'? They will be removed permanently.", - "Close all", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); - if (r != MessageBoxResult.Yes) return; - foreach (var vm in targets) - vm.CloseCommand.Execute(null); + "Close all", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) + == MessageBoxResult.Yes); }; menu.Items.Add(closeAll); @@ -3639,11 +3641,11 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM var closeItem = new System.Windows.Controls.MenuItem { Header = $"Close{countSuffix}" }; closeItem.Click += (_, _) => { - foreach (var id in targetIds.ToArray()) - { - var target = _vm.Sessions.FirstOrDefault(s => s.Id == id); - target?.CloseCommand.Execute(null); - } + var targets = targetIds + .Select(id => _vm.Sessions.FirstOrDefault(s => s.Id == id)) + .OfType() + .ToList(); + RequestCloseSessions(targets); }; menu.Items.Add(closeItem); @@ -4683,7 +4685,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) Margin = new Thickness(0) }; System.Windows.Automation.AutomationProperties.SetAutomationId(closeBtn, "TerminalCloseBtn"); - closeBtn.Click += (_, _) => vm.CloseCommand.Execute(null); + closeBtn.Click += (_, _) => RequestCloseSession(vm); DockPanel.SetDock(closeBtn, Dock.Right); DockPanel.SetDock(termStatusDot, Dock.Right); @@ -6255,7 +6257,7 @@ private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods) if (_vm.ActiveSession != null) _ = DuplicateSessionAsync(_vm.ActiveSession); return true; } - if (key == Key.W && mods == ModifierKeys.Control) { _vm.ActiveSession?.CloseCommand.Execute(null); return true; } + if (key == Key.W && mods == ModifierKeys.Control) { RequestCloseSession(_vm.ActiveSession); 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; } @@ -6299,6 +6301,80 @@ private void CycleSession(bool forward) // ── Window close ────────────────────────────────────────────────────────── + /// + /// The "sessions are still running — close anyway?" check. True = go ahead and close. + /// Skipped when the setting is off, when Windows is ending the session, and when nothing + /// is busy (). + /// + private bool ConfirmCloseWithBusySessions() + { + if (_osSessionEnding) return true; + return ConfirmCloseBusy(_vm.Sessions, + "Closing now stops them. Claude sessions are asked to exit cleanly; run commands and other programs are ended.", + out _); + } + + /// + /// The shared "still running — close anyway?" check for app close and session close. + /// True = go ahead. says whether the dialog was shown, so a + /// bulk "Close all…" can skip its own plain Yes/No when this already asked. + /// + private bool ConfirmCloseBusy(IEnumerable targets, string detail, out bool anyBusy) + { + anyBusy = false; + if (!_vm.Settings.WarnOnCloseWhenBusy) return true; + + var now = DateTime.UtcNow; + var rows = new List(); + foreach (var vm in targets) + { + if (vm.BusyReason(now) is { } reason) + rows.Add(new Views.BusySessionsDialog.Row(vm.DisplayName, vm.AccentColor, reason)); + } + if (rows.Count == 0) return true; + anyBusy = true; + + // A minimized window would centre the dialog on nothing visible. + if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal; + Activate(); + + bool close = Views.BusySessionsDialog.Confirm(this, rows, detail, out bool dontAskAgain); + if (close && dontAskAgain) + { + _vm.Settings.WarnOnCloseWhenBusy = false; + _ = _vm.SaveStateAsync(); + } + return close; + } + + private const string SessionCloseDetail = + "Closing stops what is running and removes the session. Ctrl+Shift+T reopens the most recently closed one."; + + /// + /// Close one session from the UI (Ctrl+W, the sidebar and toolbar ✕) — asks first when + /// it is busy. Programmatic closes (worktree removal, which confirms on its own) call + /// CloseCommand directly. + /// + private void RequestCloseSession(SessionViewModel? vm) + { + if (vm == null) return; + if (!ConfirmCloseBusy(new[] { vm }, SessionCloseDetail, out _)) return; + vm.CloseCommand.Execute(null); + } + + /// + /// Close several sessions from a menu. is the Yes/No a + /// bulk "Close all…" asks when nothing is busy; when something is, the busy dialog is the + /// one question asked. + /// + private void RequestCloseSessions(IReadOnlyList targets, Func? plainConfirm = null) + { + if (targets.Count == 0) return; + if (!ConfirmCloseBusy(targets, SessionCloseDetail, out bool asked)) return; + if (!asked && plainConfirm != null && !plainConfirm()) return; + foreach (var vm in targets) vm.CloseCommand.Execute(null); + } + protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) { // Final entry: cleanup is finished, let WPF tear the window down for real. @@ -6314,6 +6390,10 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) // Re-entry during async cleanup (e.g. user double-clicks the X) — just suppress. if (_isShuttingDown) return; + + // Ask first when something is still running. e.Cancel is already true, so returning + // here simply keeps the app open. + if (!ConfirmCloseWithBusySessions()) return; _isShuttingDown = true; var all = _vm.Sessions.ToList(); diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index 8c17094..02de55c 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -52,6 +52,12 @@ public class AppSettings /// public int ClaudeLaunchStaggerMs { get; set; } = 2000; public bool AutoFocusTerminalOnSelect { get; set; } = true; + /// + /// Ask before closing the app — or closing sessions (Ctrl+W, ✕, Close / Close all…) — while + /// any of them is busy (working, mid-task, producing output, or running a run command). + /// See Services/SessionBusyRules. + /// + public bool WarnOnCloseWhenBusy { 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/Services/SessionBusyRules.cs b/src/CodeShellManager/Services/SessionBusyRules.cs new file mode 100644 index 0000000..ee37956 --- /dev/null +++ b/src/CodeShellManager/Services/SessionBusyRules.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace CodeShellManager.Services; + +/// +/// Decides whether a live session is "busy" — doing something that closing the app would +/// cut off — and says why, for the "still running" warning on app close. +/// +/// WPF-free on purpose, like : the rule is the only part +/// of that warning worth testing. +/// +/// +/// A session that speaks OSC 7501 is judged by its report alone: working +/// (with progress) and blocked (mid-turn, waiting for an answer) are busy; +/// idle/done/error are not. Its output is ignored — an idle TUI may still repaint. +/// Any other session is busy while it has produced output within +/// : a Claude spinner, a build, a dev server. An idle +/// shell prompt or a heuristic "waiting for input" prompt prints nothing. +/// A running run command is busy in either case. +/// +/// +public static class SessionBusyRules +{ + /// How recent the last PTY output must be for a non-7501 session to count as busy. + public static readonly TimeSpan RecentOutputWindow = TimeSpan.FromSeconds(4); + + /// + /// Why the session is busy, or null when it is idle. Pure function of its inputs. + /// + public static string? Reason( + bool hasProgramStatus, + ProgramState? agentState, + int? progress, + int runningRunCommands, + DateTime? lastOutputUtc, + DateTime nowUtc) + { + var parts = new List(2); + + if (hasProgramStatus) + { + if (agentState == ProgramState.Working) + parts.Add(progress is int p ? $"working · {p}%" : "working"); + else if (agentState == ProgramState.Blocked) + parts.Add("waiting for you mid-task"); + } + else if (lastOutputUtc is DateTime last) + { + // A negative age is output that landed after `now` was read (the PTY reader + // runs on its own thread) — that is as recent as it gets, not clock noise. + if (nowUtc - last <= RecentOutputWindow) + parts.Add("producing output"); + } + + if (runningRunCommands == 1) parts.Add("1 run command running"); + else if (runningRunCommands > 1) parts.Add($"{runningRunCommands} run commands running"); + + return parts.Count == 0 ? null : string.Join(" · ", parts); + } +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index f16841f..a273797 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -62,6 +62,37 @@ public bool GitInfoLoaded public OutputIndexer? OutputIndexer { get; set; } public SessionRunner Runner { get; } + // UTC ticks of the last PTY output chunk; 0 = none yet. Written from the PTY reader + // thread on every chunk, read on the UI thread at app close — hence a plain long with + // Volatile access rather than a property with change notification. + private long _lastOutputTicks; + + /// Called from the PTY output path (any thread). Allocation-free. + public void NoteOutput() => Volatile.Write(ref _lastOutputTicks, DateTime.UtcNow.Ticks); + + /// When the session last produced PTY output, or null if it never has. + public DateTime? LastOutputUtc + { + get + { + long t = Volatile.Read(ref _lastOutputTicks); + return t == 0 ? null : new DateTime(t, DateTimeKind.Utc); + } + } + + /// + /// Why closing the app now would interrupt this session, or null when it is idle. + /// See . + /// + public string? BusyReason(DateTime nowUtc) + { + int running = 0; + foreach (var inst in Runner.Instances.Values) + if (inst.State == RunState.Running) running++; + return SessionBusyRules.Reason(HasProgramStatus, AgentState, WorkProgress, + running, LastOutputUtc, nowUtc); + } + public string Id => Session.Id; public string Name => Session.Name; public string WorkingFolder => Session.WorkingFolder; diff --git a/src/CodeShellManager/Views/BusySessionsDialog.xaml b/src/CodeShellManager/Views/BusySessionsDialog.xaml new file mode 100644 index 0000000..77c3fce --- /dev/null +++ b/src/CodeShellManager/Views/BusySessionsDialog.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + +