From 89500e4ebefcaeda9524293a9b13a6547c957651 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 04:31:00 -0500 Subject: [PATCH] feat(commands): /sessions prune slash command (#406 phase-1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit shipped \`SessionManager::prune_sessions_older_than\` as a bare helper marked \`#[allow(dead_code)]\` pending phase-2 wiring. This commit wires it into a user-callable slash command so users can clean up stale sessions today, and removes the dead-code allow. ### Surface \`\`\` /sessions → open the picker (existing) /sessions show | list | picker → alias for the picker /sessions prune → drop sessions older than N days \`\`\` \`/sessions prune 30\` returns "pruned N sessions older than 30d" or "no sessions older than 30d to prune". Errors: - missing arg → usage hint - non-positive / non-integer arg → typed error - unknown subcommand → typed error with usage The prune handler builds a fresh \`SessionManager\` from \`default_location\` so it reads the same \`~/.deepseek/sessions/\` directory the persistence layer writes; doesn't take a lock since it's a one-shot CLI-style operation that runs to completion. ### What changed - \`commands::session::sessions\` now takes \`arg: Option<&str>\` and dispatches \`show\` / \`prune\` / unknown. - New \`prune\` private fn parses the days argument, opens \`SessionManager::default_location\`, calls \`prune_sessions_older_than\` with the corresponding \`Duration\`. - \`commands::COMMANDS\` table updated: usage now reads \`/sessions [show|prune ]\`. - \`commands::mod\` dispatch arm passes \`arg\` through. - \`SessionManager::prune_sessions_older_than\` doc comment updated to reflect the live wiring; \`#[allow(dead_code)]\` removed. ### Tests 5 new tests in \`commands::session::tests\`: - existing \`test_sessions_pushes_picker_view\` updated to the new signature - \`test_sessions_show_subcommand_pushes_picker_view\` — \`/sessions show\` is an explicit alias for the picker - \`test_sessions_prune_requires_days_argument\` — missing arg produces usage hint - \`test_sessions_prune_rejects_non_positive_days\` — \`0\`, negative, non-numeric, and decimal inputs are all rejected - \`test_sessions_unknown_subcommand_errors\` — typo path errors with subcommand list ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ 1865 + supporting Refines #406 — phase 1.5 (user-callable surface) shipped on top of phase 1 (helper). Phase 2 (boot-prune + retention policy) stays open for v0.8.9 once the policy is decided. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/commands/mod.rs | 4 +- crates/tui/src/commands/session.rs | 121 +++++++++++++++++++++++++++-- crates/tui/src/session_manager.rs | 7 +- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index cddfe68f..db220021 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -229,7 +229,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "sessions", aliases: &["resume"], - usage: "/sessions", + usage: "/sessions [show|prune ]", description_id: MessageId::CmdSessionsDescription, }, CommandInfo { @@ -472,7 +472,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Session commands "save" => session::save(app, arg), - "sessions" | "resume" => session::sessions(app), + "sessions" | "resume" => session::sessions(app, arg), "load" => session::load(app, arg), "compact" => session::compact(app), "cycles" => cycle::list_cycles(app), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index ebcacbca..9bd2b496 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -183,10 +183,68 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { } } -/// Open the session picker UI -pub fn sessions(app: &mut App) -> CommandResult { - app.view_stack.push(SessionPickerView::new()); - CommandResult::ok() +/// Open the session picker UI, or run a sub-action like +/// `prune ` for housekeeping (#406 phase-1.5). +pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { + let trimmed = arg.unwrap_or("").trim(); + if trimmed.is_empty() { + app.view_stack.push(SessionPickerView::new()); + return CommandResult::ok(); + } + + let mut parts = trimmed.split_whitespace(); + let action = parts.next().unwrap_or("").to_ascii_lowercase(); + match action.as_str() { + "prune" => prune(app, parts.next()), + "show" | "list" | "picker" => { + app.view_stack.push(SessionPickerView::new()); + CommandResult::ok() + } + _ => CommandResult::error(format!( + "unknown subcommand `{action}`. usage: /sessions [show|prune ]" + )), + } +} + +/// Prune persisted sessions older than `` from +/// `~/.deepseek/sessions/`. Wraps +/// [`SessionManager::prune_sessions_older_than`] so users can run a +/// safe cleanup without leaving the TUI. Skips the checkpoint +/// subdirectory (the helper guarantees that already). +fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { + let days_str = match days_arg { + Some(s) => s, + None => { + return CommandResult::error( + "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)", + ); + } + }; + let days: u64 = match days_str.parse() { + Ok(n) if n > 0 => n, + _ => { + return CommandResult::error(format!( + "expected a positive integer number of days, got `{days_str}`" + )); + } + }; + + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(err) => { + return CommandResult::error(format!("could not open sessions directory: {err}")); + } + }; + + let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60)); + match manager.prune_sessions_older_than(max_age) { + Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")), + Ok(n) => CommandResult::message(format!( + "pruned {n} session{} older than {days}d", + if n == 1 { "" } else { "s" } + )), + Err(err) => CommandResult::error(format!("prune failed: {err}")), + } } fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String { @@ -410,10 +468,63 @@ mod tests { let mut app = create_test_app_with_tmpdir(&tmpdir); let initial_kind = app.view_stack.top_kind(); - let result = sessions(&mut app); + let result = sessions(&mut app, None); assert_eq!(result.message, None); assert!(result.action.is_none()); // View should have changed (session picker should be on top) assert_ne!(app.view_stack.top_kind(), initial_kind); } + + #[test] + fn test_sessions_show_subcommand_pushes_picker_view() { + // `/sessions show` and `/sessions list` are explicit aliases + // for the no-arg picker form. Verify they don't fall through + // to the prune branch. + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let initial_kind = app.view_stack.top_kind(); + let result = sessions(&mut app, Some("show")); + assert_eq!(result.message, None); + assert_ne!(app.view_stack.top_kind(), initial_kind); + } + + #[test] + fn test_sessions_prune_requires_days_argument() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = sessions(&mut app, Some("prune")); + assert!(result.is_error); + assert!( + result.message.as_deref().unwrap_or("").contains("usage"), + "expected usage hint: {:?}", + result.message + ); + } + + #[test] + fn test_sessions_prune_rejects_non_positive_days() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + for bad in ["0", "-3", "abc", "3.14"] { + let result = sessions(&mut app, Some(&format!("prune {bad}"))); + assert!(result.is_error, "expected error for `{bad}`"); + } + } + + #[test] + fn test_sessions_unknown_subcommand_errors() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = sessions(&mut app, Some("teleport")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or("") + .contains("unknown subcommand"), + "expected unknown-subcommand error: {:?}", + result.message + ); + } } diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index ccca7773..e7de9bff 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -398,10 +398,8 @@ impl SessionManager { /// Remove session files whose `updated_at` is older than `max_age` /// from the persisted-sessions directory. Returns the number of /// records pruned. Building block for #406's phase-2 auto-archive - /// on boot — exposing the helper without wiring it into startup - /// lets the next person opt the behaviour in behind a config knob - /// once the archive policy is decided. Tests in this module pin - /// the contract; production callers will land in a follow-up PR. + /// on boot; today the user-facing entry point is the + /// `/sessions prune ` slash command. /// /// Crash-recovery safety: skips the running checkpoint /// (`checkpoints/latest.json`) and any file under `checkpoints/` @@ -413,7 +411,6 @@ impl SessionManager { /// timestamp embedded in the JSON, not the filesystem mtime — the /// user may have rsynced their `~/.deepseek` between machines and /// fs mtimes can lie. - #[allow(dead_code)] // wired in phase 2 (#406) pub fn prune_sessions_older_than( &self, max_age: std::time::Duration,