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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
220 changes: 220 additions & 0 deletions src/CodeShellManager/MainWindow.Recent.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// "πŸ•˜ 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 <see cref="RecentSessions"/>.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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;
}
}
3 changes: 3 additions & 0 deletions src/CodeShellManager/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@

<Button DockPanel.Dock="Left" AutomationProperties.AutomationId="NewSessionBtn" Content="οΌ‹ New Session" Style="{StaticResource ToolBtn}"
Foreground="#a6e3a1" Click="NewSession_Click" Margin="0,0,4,0"/>
<Button x:Name="RecentSessionsBtn" DockPanel.Dock="Left" AutomationProperties.AutomationId="RecentSessionsBtn"
Content="πŸ•˜ Recent β–Ύ" Style="{StaticResource ToolBtn}" Click="RecentSessions_Click" Margin="0,0,4,0"
ToolTip="Jump to a recently used session (Ctrl+Shift+E)"/>
</DockPanel>
</Border>

Expand Down
7 changes: 6 additions & 1 deletion src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions src/CodeShellManager/Services/RecentSessions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CodeShellManager.Models;

namespace CodeShellManager.Services;

/// <summary>
/// The "πŸ•˜ Recent" toolbar menu (Ctrl+Shift+E): the sessions used most recently, newest first.
/// WPF-free so ordering and wording are unit-tested (<c>RecentSessionsTests</c>).
///
/// "Used" is <see cref="ShellSession.LastActivityAt"/>, 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.
/// </summary>
public static class RecentSessions
{
/// <summary>How many sessions the menu lists.</summary>
public const int MaxItems = 10;

/// <summary>
/// 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).
/// </summary>
public static IReadOnlyList<ShellSession> Pick(
IEnumerable<ShellSession> 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();

/// <summary>Compact "how long ago": now, 5m, 3h, 2d, 6w; blank for "never".</summary>
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";
}
}
Loading
Loading