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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user