feat(commands): /sessions prune <days> slash command (#406 phase-1.5)

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 <days>         → 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 <days>]\`.
- \`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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 04:31:00 -05:00
parent 2fa23c1d74
commit 89500e4ebe
3 changed files with 120 additions and 12 deletions
+2 -2
View File
@@ -229,7 +229,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "sessions",
aliases: &["resume"],
usage: "/sessions",
usage: "/sessions [show|prune <days>]",
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),
+116 -5
View File
@@ -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 <days>` 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 <days>]"
)),
}
}
/// Prune persisted sessions older than `<days>` 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 <days> (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
);
}
}
+2 -5
View File
@@ -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 <days>` 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,