diff --git a/CLAUDE.md b/CLAUDE.md
index f1796b0..e434a2b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -739,6 +739,22 @@ The snapshot model is `Models/RecentlyClosedEntry.cs` β a separate POCO from `
FTS5 scrollback retention is **out of scope** for v1 β restored sessions start with an empty xterm buffer.
+## Recent sessions menu
+
+**π Recent βΎ** on the main toolbar (`RecentSessionsBtn`) and **Ctrl+Shift+E** open a menu of the
+10 most recently used sessions, live and sleeping, newest first (`MainWindow.Recent.cs`;
+ordering and the "5m / 3h / 2d" wording in the WPF-free `Services/RecentSessions`,
+`RecentSessionsTests`). The active session is left out, so the first item is the previous one
+and Ctrl+Shift+E, Enter flips between two sessions; digits 1β9/0 pick directly. Each row shows
+the accent, name, folder, status (β³ working, β waiting, ! attention, π€ asleep) and how long
+ago. Picking a live session focuses it β in filter-strip mode switching to its group tab if the
+current tab hides it; a sleeping one is woken. Not plain Ctrl+E: that is end-of-line in bash.
+
+"Used" is `ShellSession.LastActivityAt`, stamped in `MainWindow`'s `ActiveSession` handler and
+persisted. **It is not stamped while the restore loop runs** (`_restoreInProgress`):
+`RegisterSession` activates every restored session in turn, which used to overwrite the saved
+order with sidebar order on every start. The `LastActive` sidebar sort benefits from the same fix.
+
## Shell Integration (OSC 9001)
Programs running inside a terminal can push session state up to CSM by emitting a custom OSC sequence β useful for SSH overlays (e.g. `nexus`) where CSM cannot inspect the remote repo locally.
@@ -1363,6 +1379,7 @@ Persisted in `state.json`. Key settings:
| `Ctrl+W` | Close active session |
| `Ctrl+F` | Toggle search |
| `Ctrl+Tab` | Cycle sessions |
+| `Ctrl+Shift+E` | Recent sessions menu β see "Recent sessions menu" |
| `F5` | Run the active session's default run command |
| `Shift+F5` | Stop the active session's default run command |
| `Escape` (in search) | Close search panel |
diff --git a/README.md b/README.md
index fa7d901..f5cdaf3 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi
- **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
- **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
+- **Recent sessions** β π Recent in the toolbar (Ctrl+Shift+E) lists the 10 sessions you used last, live or asleep, with their status; Enter jumps back to the previous one, 1β9/0 pick directly
- **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
- **Per-project notepad** β collapsible π notes panel on every terminal, auto-saved and searchable
@@ -141,6 +142,7 @@ dotnet run --project src/CodeShellManager/CodeShellManager.csproj
| `Ctrl+W` | Close active session |
| `Ctrl+F` | Toggle search |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle sessions |
+| `Ctrl+Shift+E` | Recent sessions menu (Enter = previous session, 1β9/0 pick) |
| `F5` | Run the active session's default run-command |
| `Shift+F5` | Stop the active session's running run-command |
| `Escape` (in search) | Close search panel |
diff --git a/src/CodeShellManager/MainWindow.Recent.cs b/src/CodeShellManager/MainWindow.Recent.cs
new file mode 100644
index 0000000..253de18
--- /dev/null
+++ b/src/CodeShellManager/MainWindow.Recent.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using CodeShellManager.Models;
+using CodeShellManager.Services;
+using CodeShellManager.ViewModels;
+using Color = System.Windows.Media.Color;
+using ColorConverter = System.Windows.Media.ColorConverter;
+using ContextMenu = System.Windows.Controls.ContextMenu;
+using HorizontalAlignment = System.Windows.HorizontalAlignment;
+using MenuItem = System.Windows.Controls.MenuItem;
+using Orientation = System.Windows.Controls.Orientation;
+using VerticalAlignment = System.Windows.VerticalAlignment;
+
+namespace CodeShellManager;
+
+///
+/// "π Recent" on the main toolbar (and Ctrl+Shift+E): jump to one of the sessions used
+/// most recently β live or sleeping β whatever group tab or layout page it is on.
+/// Ordering and wording live in the WPF-free .
+///
+public partial class MainWindow
+{
+ private void RecentSessions_Click(object sender, RoutedEventArgs e)
+ => ShowRecentSessionsMenu(sender as FrameworkElement ?? RecentSessionsBtn);
+
+ private void ShowRecentSessionsMenu(FrameworkElement anchor)
+ {
+ var menu = new ContextMenu
+ {
+ PlacementTarget = anchor,
+ Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
+ };
+
+ var live = _vm.Sessions.ToDictionary(v => v.Id);
+ var picked = RecentSessions.Pick(_sessionManager.Sessions, _vm.ActiveSession?.Id);
+ var now = DateTime.UtcNow;
+
+ if (picked.Count == 0)
+ {
+ menu.Items.Add(new MenuItem
+ {
+ Header = _sessionManager.Sessions.Count == 0 ? "No sessions yet" : "No other sessions",
+ IsEnabled = false,
+ });
+ }
+
+ for (int i = 0; i < picked.Count; i++)
+ {
+ var session = picked[i];
+ live.TryGetValue(session.Id, out var vm);
+ var item = new MenuItem
+ {
+ Header = BuildRecentHeader(session, vm, now),
+ // 1β9 and 0 as access keys, so the menu works from the keyboard alone.
+ InputGestureText = i < 10 ? ((i + 1) % 10).ToString() : "",
+ ToolTip = string.IsNullOrEmpty(session.WorkingFolder) ? null : session.WorkingFolder,
+ };
+ // The header is a Grid, so UI Automation would otherwise read the item as unnamed.
+ System.Windows.Automation.AutomationProperties.SetName(item, RecentItemName(session, vm));
+ item.Click += async (_, _) =>
+ {
+ if (vm != null) JumpToSession(vm);
+ else if (session.IsDormant) await WakeSessionAsync(session);
+ };
+ menu.Items.Add(item);
+ }
+
+ // Digits pick an item directly (shown as each item's gesture text).
+ menu.PreviewKeyDown += (_, ke) =>
+ {
+ int digit = ke.Key switch
+ {
+ >= System.Windows.Input.Key.D0 and <= System.Windows.Input.Key.D9 => ke.Key - System.Windows.Input.Key.D0,
+ >= System.Windows.Input.Key.NumPad0 and <= System.Windows.Input.Key.NumPad9 => ke.Key - System.Windows.Input.Key.NumPad0,
+ _ => -1,
+ };
+ if (digit < 0) return;
+ int idx = digit == 0 ? 9 : digit - 1;
+ if (idx < picked.Count && menu.Items[idx] is MenuItem mi && mi.IsEnabled)
+ {
+ ke.Handled = true;
+ menu.IsOpen = false;
+ mi.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ }
+ };
+
+ // Keyboard users land on the first item, so Ctrl+Shift+E, Enter flips back to the
+ // previous session.
+ menu.Opened += (_, _) =>
+ {
+ if (menu.Items.Count > 0 && menu.Items[0] is MenuItem first)
+ first.Focus();
+ };
+ menu.IsOpen = true;
+ }
+
+ ///
+ /// Focuses a live session and makes sure it is on screen: in filter-strip mode a session
+ /// outside the selected group tab would otherwise be focused but invisible, so the tab
+ /// switches to the session's own group.
+ ///
+ private void JumpToSession(SessionViewModel vm)
+ {
+ if (_vm.Settings.GroupDisplayMode == GroupDisplayMode.FilterStrip
+ && _vm.ActiveGroupId is { } groupFilter)
+ {
+ bool inFilter = groupFilter == GroupFilter.Ungrouped
+ ? string.IsNullOrEmpty(vm.Session.GroupId)
+ : vm.Session.GroupId == groupFilter;
+ if (!inFilter)
+ _vm.ActiveGroupId = string.IsNullOrEmpty(vm.Session.GroupId)
+ ? GroupFilter.Ungrouped
+ : vm.Session.GroupId;
+ }
+
+ _vm.FocusSessionCommand.Execute(vm);
+ UpdateSidebarActiveState();
+ RefreshTerminalLayout();
+ }
+
+ private static readonly SolidColorBrush RecentMutedBrush = Freeze(Color.FromRgb(0x6c, 0x70, 0x86));
+ private static readonly SolidColorBrush RecentWorkingBrush = Freeze(Color.FromRgb(0x89, 0xb4, 0xfa));
+ private static readonly SolidColorBrush RecentApprovalBrush = Freeze(Color.FromRgb(0xfa, 0xb3, 0x87));
+ private static readonly SolidColorBrush RecentInputBrush = Freeze(Color.FromRgb(0xa6, 0xe3, 0xa1));
+ private static readonly SolidColorBrush RecentAlertBrush = Freeze(Color.FromRgb(0xf3, 0x8b, 0xa8));
+
+ private static SolidColorBrush Freeze(Color c)
+ {
+ var b = new SolidColorBrush(c);
+ b.Freeze();
+ return b;
+ }
+
+ private static string RecentItemName(ShellSession session, SessionViewModel? vm)
+ => vm?.DisplayName
+ ?? (string.IsNullOrWhiteSpace(session.Name) ? session.DefaultDisplayName : session.Name);
+
+ private FrameworkElement BuildRecentHeader(ShellSession session, SessionViewModel? vm, DateTime nowUtc)
+ {
+ var grid = new Grid { MinWidth = 320 };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name + folder
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // status
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // ago
+
+ Color accent;
+ try { accent = (Color)ColorConverter.ConvertFromString(vm?.AccentColor ?? GetAccentForSession(session)); }
+ catch { accent = Color.FromRgb(0x6c, 0x70, 0x86); }
+ var dot = new System.Windows.Shapes.Ellipse
+ {
+ Width = 8,
+ Height = 8,
+ Fill = new SolidColorBrush(accent),
+ Opacity = vm == null ? 0.55 : 1.0,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ };
+ Grid.SetColumn(dot, 0);
+
+ string name = RecentItemName(session, vm);
+ var text = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
+ text.Children.Add(new TextBlock
+ {
+ Text = name,
+ FontWeight = FontWeights.SemiBold,
+ MaxWidth = 260,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ });
+ if (!string.IsNullOrEmpty(session.FolderShort) && session.FolderShort != name)
+ text.Children.Add(new TextBlock
+ {
+ Text = " " + session.FolderShort,
+ Foreground = RecentMutedBrush,
+ MaxWidth = 200,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ });
+ Grid.SetColumn(text, 1);
+
+ var (glyph, brush) = vm switch
+ {
+ null when session.IsDormant => ("π€", RecentMutedBrush),
+ { IsWorking: true } => ("β³", RecentWorkingBrush),
+ { IsWaitingForApproval: true } => ("β", RecentApprovalBrush),
+ { IsWaitingForInput: true } => ("β", RecentInputBrush),
+ { NeedsAttention: true } => ("!", RecentAlertBrush),
+ _ => ("", RecentMutedBrush),
+ };
+ var status = new TextBlock
+ {
+ Text = glyph,
+ Foreground = brush,
+ Margin = new Thickness(10, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetColumn(status, 2);
+
+ var ago = new TextBlock
+ {
+ Text = RecentSessions.Ago(session.LastActivityAt, nowUtc),
+ Foreground = RecentMutedBrush,
+ FontFamily = new System.Windows.Media.FontFamily("Consolas"),
+ FontSize = 10.5,
+ MinWidth = 28,
+ TextAlignment = TextAlignment.Right,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(10, 0, 0, 0),
+ };
+ Grid.SetColumn(ago, 3);
+
+ grid.Children.Add(dot);
+ grid.Children.Add(text);
+ grid.Children.Add(status);
+ grid.Children.Add(ago);
+ return grid;
+ }
+}
diff --git a/src/CodeShellManager/MainWindow.xaml b/src/CodeShellManager/MainWindow.xaml
index 0fd7e04..6b8ae85 100644
--- a/src/CodeShellManager/MainWindow.xaml
+++ b/src/CodeShellManager/MainWindow.xaml
@@ -219,6 +219,9 @@
+
diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs
index 5a1d0b8..006c8c8 100644
--- a/src/CodeShellManager/MainWindow.xaml.cs
+++ b/src/CodeShellManager/MainWindow.xaml.cs
@@ -147,7 +147,10 @@ public MainWindow()
{
if (args.PropertyName == nameof(MainViewModel.ActiveSession))
{
- if (_vm.ActiveSession != null)
+ // Not while restoring: RegisterSession activates each restored session in
+ // turn, which would overwrite the saved "last used" order with sidebar order
+ // and leave the Recent menu (Ctrl+Shift+E) useless after every restart.
+ if (_vm.ActiveSession != null && !_restoreInProgress)
_vm.ActiveSession.Session.LastActivityAt = DateTime.UtcNow;
RefreshTerminalLayout();
UpdateSidebarActiveState();
@@ -6257,6 +6260,8 @@ private bool TryHandleGlobalShortcut(Key key, ModifierKeys mods)
}
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; }
+ // Recent sessions menu. Ctrl+Shift, not Ctrl+E: plain Ctrl+E is end-of-line in bash.
+ if (key == Key.E && mods == (ModifierKeys.Control | ModifierKeys.Shift)) { ShowRecentSessionsMenu(RecentSessionsBtn); 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; }
if (key == Key.F5 && mods == ModifierKeys.None)
diff --git a/src/CodeShellManager/Services/RecentSessions.cs b/src/CodeShellManager/Services/RecentSessions.cs
new file mode 100644
index 0000000..14c91ec
--- /dev/null
+++ b/src/CodeShellManager/Services/RecentSessions.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CodeShellManager.Models;
+
+namespace CodeShellManager.Services;
+
+///
+/// The "π Recent" toolbar menu (Ctrl+Shift+E): the sessions used most recently, newest first.
+/// WPF-free so ordering and wording are unit-tested (RecentSessionsTests).
+///
+/// "Used" is , stamped whenever a session becomes the
+/// active one (sidebar click, typing or clicking in its pane, Ctrl+Tab, β¦) and persisted, so
+/// the list survives a restart. The restore loop does not stamp β see MainWindow's
+/// ActiveSession handler.
+///
+public static class RecentSessions
+{
+ /// How many sessions the menu lists.
+ public const int MaxItems = 10;
+
+ ///
+ /// Live and sleeping sessions by most recent use, without the active one (it is already
+ /// on screen β leaving it out makes the first item "the session I was in before", so
+ /// Ctrl+Shift+E, Enter flips between the two).
+ ///
+ public static IReadOnlyList Pick(
+ IEnumerable sessions, string? activeId, int max = MaxItems)
+ => sessions
+ .Where(s => s.Id != activeId)
+ .Select((s, i) => (s, i))
+ // Stable on ties: keep sidebar order for sessions never used (same backfilled stamp).
+ .OrderByDescending(t => t.s.LastActivityAt)
+ .ThenBy(t => t.i)
+ .Take(Math.Max(0, max))
+ .Select(t => t.s)
+ .ToList();
+
+ /// Compact "how long ago": now, 5m, 3h, 2d, 6w; blank for "never".
+ public static string Ago(DateTime thenUtc, DateTime nowUtc)
+ {
+ if (thenUtc == default) return "";
+ var d = nowUtc - thenUtc;
+ if (d < TimeSpan.FromMinutes(1)) return "now";
+ if (d < TimeSpan.FromHours(1)) return $"{(int)d.TotalMinutes}m";
+ if (d < TimeSpan.FromDays(1)) return $"{(int)d.TotalHours}h";
+ if (d < TimeSpan.FromDays(14)) return $"{(int)d.TotalDays}d";
+ return $"{(int)(d.TotalDays / 7)}w";
+ }
+}
diff --git a/tests/CodeShellManager.Tests/RecentSessionsTests.cs b/tests/CodeShellManager.Tests/RecentSessionsTests.cs
new file mode 100644
index 0000000..6691fa9
--- /dev/null
+++ b/tests/CodeShellManager.Tests/RecentSessionsTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Linq;
+using CodeShellManager.Models;
+using CodeShellManager.Services;
+using Xunit;
+
+namespace CodeShellManager.Tests;
+
+public class RecentSessionsTests
+{
+ private static readonly DateTime Now = new(2026, 11, 6, 12, 0, 0, DateTimeKind.Utc);
+
+ private static ShellSession S(string id, double minutesAgo, bool dormant = false)
+ => new() { Id = id, Name = id, LastActivityAt = Now.AddMinutes(-minutesAgo), IsDormant = dormant };
+
+ [Fact]
+ public void NewestFirst_ActiveExcluded()
+ {
+ var list = new[] { S("a", 30), S("b", 1), S("c", 10), S("d", 0) };
+ var ids = RecentSessions.Pick(list, activeId: "d").Select(s => s.Id);
+ Assert.Equal(new[] { "b", "c", "a" }, ids);
+ }
+
+ [Fact]
+ public void SleepingSessions_AreIncluded()
+ {
+ var list = new[] { S("live", 20), S("asleep", 5, dormant: true) };
+ Assert.Equal("asleep", RecentSessions.Pick(list, null)[0].Id);
+ }
+
+ [Fact]
+ public void CappedAtMax()
+ {
+ var list = Enumerable.Range(0, 25).Select(i => S($"s{i}", i)).ToArray();
+ var picked = RecentSessions.Pick(list, null);
+ Assert.Equal(RecentSessions.MaxItems, picked.Count);
+ Assert.Equal("s0", picked[0].Id);
+ }
+
+ [Fact]
+ public void Ties_KeepSidebarOrder()
+ {
+ var list = new[] { S("x", 60), S("y", 60), S("z", 60) };
+ Assert.Equal(new[] { "x", "y", "z" }, RecentSessions.Pick(list, null).Select(s => s.Id));
+ }
+
+ [Fact]
+ public void NoActive_AllCandidates()
+ => Assert.Equal(2, RecentSessions.Pick(new[] { S("a", 1), S("b", 2) }, null).Count);
+
+ [Theory]
+ [InlineData(0.2, "now")]
+ [InlineData(5, "5m")]
+ [InlineData(59, "59m")]
+ [InlineData(60, "1h")]
+ [InlineData(60 * 23, "23h")]
+ [InlineData(60 * 24 * 3, "3d")]
+ [InlineData(60 * 24 * 30, "4w")]
+ public void Ago_Compact(double minutesAgo, string expected)
+ => Assert.Equal(expected, RecentSessions.Ago(Now.AddMinutes(-minutesAgo), Now));
+
+ [Fact]
+ public void Ago_Never_IsBlank() => Assert.Equal("", RecentSessions.Ago(default, Now));
+}
diff --git a/tests/CodeShellManager.UITests/RecentSessionsTests.cs b/tests/CodeShellManager.UITests/RecentSessionsTests.cs
new file mode 100644
index 0000000..c5f5b06
--- /dev/null
+++ b/tests/CodeShellManager.UITests/RecentSessionsTests.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Linq;
+using System.Threading;
+using CodeShellManager.UITests.Helpers;
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Definitions;
+using FlaUI.Core.Tools;
+using Xunit;
+
+namespace CodeShellManager.UITests;
+
+/// The "π Recent" toolbar menu lists other sessions, most recently used first.
+[Collection("UITests")]
+public sealed class RecentSessionsTests : IClassFixture
+{
+ private readonly AppFixture _f;
+
+ public RecentSessionsTests(AppFixture fixture) => _f = fixture;
+
+ private MenuItem[] OpenRecentMenu()
+ {
+ AppActions.Press(AppActions.WaitForElement(_f.MainWindow, "RecentSessionsBtn"));
+ // A ContextMenu is a popup: UI Automation lists it as an unnamed top-level window of
+ // the app's process with the Menu inside β not under the main window.
+ var menu = Retry.WhileNull(
+ () => _f.Automation.GetDesktop()
+ .FindAllChildren(cf => cf.ByProcessId(_f.App.ProcessId))
+ .Select(w => w.FindFirstChild(cf => cf.ByControlType(ControlType.Menu)))
+ .FirstOrDefault(m => m != null),
+ TimeSpan.FromSeconds(5)).Result
+ ?? throw new TimeoutException("Recent menu did not open.");
+ return menu.FindAllChildren(cf => cf.ByControlType(ControlType.MenuItem))
+ .Select(e => e.AsMenuItem()).ToArray();
+ }
+
+ [Fact]
+ public void RecentMenu_ListsPreviousSessionFirst_AndJumpsToIt()
+ {
+ AppActions.CreateSession(_f.App, _f.MainWindow, _f.Automation, folder: @"C:\Windows", name: "RecentA");
+ Thread.Sleep(1500);
+ AppActions.CreateSession(_f.App, _f.MainWindow, _f.Automation, folder: @"C:\Windows", name: "RecentB");
+ Thread.Sleep(1500);
+
+ // B is active (just created), so the first entry is the one used before it: A.
+ var items = OpenRecentMenu();
+ Assert.Equal("RecentA", items[0].Name);
+ Assert.DoesNotContain(items, i => i.Name == "RecentB");
+
+ items[0].Invoke(); // jump to A
+ Thread.Sleep(800);
+
+ // Now A is active, so B is the previous one.
+ items = OpenRecentMenu();
+ Assert.Equal("RecentB", items[0].Name);
+ Assert.DoesNotContain(items, i => i.Name == "RecentA");
+ items[0].Invoke();
+ }
+}