Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <sessionId>` 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.<pid>.<hash>` files with the same timestamp and different pids.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 100 additions & 20 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) =>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<SessionViewModel>()
.ToList();
RequestCloseSessions(targets);
};
menu.Items.Add(closeItem);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -6299,6 +6301,80 @@ private void CycleSession(bool forward)

// ── Window close ──────────────────────────────────────────────────────────

/// <summary>
/// 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 (<see cref="SessionBusyRules"/>).
/// </summary>
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 _);
}

/// <summary>
/// The shared "still running — close anyway?" check for app close and session close.
/// True = go ahead. <paramref name="anyBusy"/> says whether the dialog was shown, so a
/// bulk "Close all…" can skip its own plain Yes/No when this already asked.
/// </summary>
private bool ConfirmCloseBusy(IEnumerable<SessionViewModel> targets, string detail, out bool anyBusy)
{
anyBusy = false;
if (!_vm.Settings.WarnOnCloseWhenBusy) return true;

var now = DateTime.UtcNow;
var rows = new List<Views.BusySessionsDialog.Row>();
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.";

/// <summary>
/// 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
/// <c>CloseCommand</c> directly.
/// </summary>
private void RequestCloseSession(SessionViewModel? vm)
{
if (vm == null) return;
if (!ConfirmCloseBusy(new[] { vm }, SessionCloseDetail, out _)) return;
vm.CloseCommand.Execute(null);
}

/// <summary>
/// Close several sessions from a menu. <paramref name="plainConfirm"/> is the Yes/No a
/// bulk "Close all…" asks when nothing is busy; when something is, the busy dialog is the
/// one question asked.
/// </summary>
private void RequestCloseSessions(IReadOnlyList<SessionViewModel> targets, Func<bool>? 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.
Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/CodeShellManager/Models/AppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public class AppSettings
/// </summary>
public int ClaudeLaunchStaggerMs { get; set; } = 2000;
public bool AutoFocusTerminalOnSelect { get; set; } = true;
/// <summary>
/// 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 <c>Services/SessionBusyRules</c>.
/// </summary>
public bool WarnOnCloseWhenBusy { get; set; } = true;
public bool ShowToastNotifications { get; set; } = false;
public bool ShowNotificationSound { get; set; } = false;
public string AnthropicApiKey { get; set; } = "";
Expand Down
61 changes: 61 additions & 0 deletions src/CodeShellManager/Services/SessionBusyRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;

namespace CodeShellManager.Services;

/// <summary>
/// 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 <see cref="BulkRestartEstimate"/>: the rule is the only part
/// of that warning worth testing.
///
/// <list type="bullet">
/// <item>A session that speaks OSC 7501 is judged by its report alone: <c>working</c>
/// (with progress) and <c>blocked</c> (mid-turn, waiting for an answer) are busy;
/// idle/done/error are not. Its output is ignored — an idle TUI may still repaint.</item>
/// <item>Any other session is busy while it has produced output within
/// <see cref="RecentOutputWindow"/>: a Claude spinner, a build, a dev server. An idle
/// shell prompt or a heuristic "waiting for input" prompt prints nothing.</item>
/// <item>A running run command is busy in either case.</item>
/// </list>
/// </summary>
public static class SessionBusyRules
{
/// <summary>How recent the last PTY output must be for a non-7501 session to count as busy.</summary>
public static readonly TimeSpan RecentOutputWindow = TimeSpan.FromSeconds(4);

/// <summary>
/// Why the session is busy, or null when it is idle. Pure function of its inputs.
/// </summary>
public static string? Reason(
bool hasProgramStatus,
ProgramState? agentState,
int? progress,
int runningRunCommands,
DateTime? lastOutputUtc,
DateTime nowUtc)
{
var parts = new List<string>(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);
}
}
31 changes: 31 additions & 0 deletions src/CodeShellManager/ViewModels/SessionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>Called from the PTY output path (any thread). Allocation-free.</summary>
public void NoteOutput() => Volatile.Write(ref _lastOutputTicks, DateTime.UtcNow.Ticks);

/// <summary>When the session last produced PTY output, or null if it never has.</summary>
public DateTime? LastOutputUtc
{
get
{
long t = Volatile.Read(ref _lastOutputTicks);
return t == 0 ? null : new DateTime(t, DateTimeKind.Utc);
}
}

/// <summary>
/// Why closing the app now would interrupt this session, or null when it is idle.
/// See <see cref="SessionBusyRules"/>.
/// </summary>
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;
Expand Down
Loading
Loading