From c3425087634967410a449a2a547d642281e8fdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 19:58:42 +0100 Subject: [PATCH 1/3] feat(close): warn before closing the app while sessions are still running Closing the window while a session is busy now lists those sessions (accent, name, why) and asks; "Keep running" is the default for Enter and Esc. Busy (Services/SessionBusyRules, WPF-free, unit-tested): - OSC 7501 sessions: working (+progress) or blocked mid-turn - other sessions: PTY output within the last 4s - any session with a running run command Skipped when the new WarnOnCloseWhenBusy setting is off (Settings, or the dialog's "Don't warn me again") and when Windows is logging off. UI test CloseWarningTests drives both answers with a `ping -t` session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 20 ++++ src/CodeShellManager/MainWindow.xaml.cs | 40 +++++++ src/CodeShellManager/Models/AppState.cs | 5 + .../Services/SessionBusyRules.cs | 61 +++++++++++ .../ViewModels/SessionViewModel.cs | 31 ++++++ .../Views/BusySessionsDialog.xaml | 43 ++++++++ .../Views/BusySessionsDialog.xaml.cs | 100 ++++++++++++++++++ .../Views/NewSessionDialog.xaml | 2 +- .../Views/SettingsWindow.xaml | 3 + .../Views/SettingsWindow.xaml.cs | 3 + .../SessionBusyRulesTests.cs | 73 +++++++++++++ tests/CodeShellManager.UITests/AppFixture.cs | 3 + .../CloseWarningTests.cs | 60 +++++++++++ 13 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/CodeShellManager/Services/SessionBusyRules.cs create mode 100644 src/CodeShellManager/Views/BusySessionsDialog.xaml create mode 100644 src/CodeShellManager/Views/BusySessionsDialog.xaml.cs create mode 100644 tests/CodeShellManager.Tests/SessionBusyRulesTests.cs create mode 100644 tests/CodeShellManager.UITests/CloseWarningTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index f1796b0..d53fc9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -909,6 +909,25 @@ 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 the app while sessions are busy + +`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. + +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. `CloseWarningTests` (UI) runs `ping -t` and +checks both answers. + ## 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 +1357,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 while a session 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/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 5a1d0b8..dc867e1 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 @@ -6299,6 +6307,34 @@ 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 || !_vm.Settings.WarnOnCloseWhenBusy) return true; + + var now = DateTime.UtcNow; + var rows = new List(); + foreach (var vm in _vm.Sessions) + { + if (vm.BusyReason(now) is { } reason) + rows.Add(new Views.BusySessionsDialog.Row(vm.DisplayName, vm.AccentColor, reason)); + } + if (rows.Count == 0) return 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, out bool dontAskAgain); + if (close && dontAskAgain) + _vm.Settings.WarnOnCloseWhenBusy = false; // persisted by the SaveStateAsync that follows + return close; + } + protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) { // Final entry: cleanup is finished, let WPF tear the window down for real. @@ -6314,6 +6350,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..072d9d0 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -52,6 +52,11 @@ public class AppSettings /// public int ClaudeLaunchStaggerMs { get; set; } = 2000; public bool AutoFocusTerminalOnSelect { get; set; } = true; + /// + /// Ask before closing the app while any session 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..cc0ebb6 --- /dev/null +++ b/src/CodeShellManager/Views/BusySessionsDialog.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + +