Expose session details without requiring manual JSON inspection

This adds a dedicated session inspect command to the Rust CLI so users can inspect a saved session's path, timestamps, size, token totals, preview text, and latest user/assistant context without opening the underlying file by hand.

It builds directly on the new session list/resume flows and keeps the UX lightweight and script-friendly.

Constraint: Keep session inspection CLI-native and read-only

Constraint: Reuse the existing saved-session format instead of introducing a secondary index format

Rejected: Add an interactive session browser now | more overhead than needed for this inspect slice

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep session inspection output stable and grep-friendly so it remains useful in scripts

Tested: cargo fmt; cargo clippy --all-targets --all-features -- -D warnings; cargo test -q

Not-tested: Manual inspection against a large corpus of real saved sessions
This commit is contained in:
Yeachan-Heo
2026-03-31 19:38:06 +00:00
parent 8465b6923b
commit add5513ac5

View File

@@ -47,6 +47,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
command, command,
} => resume_session(&session_path, command), } => resume_session(&session_path, command),
CliAction::ResumeNamed { target, command } => resume_named_session(&target, command), CliAction::ResumeNamed { target, command } => resume_named_session(&target, command),
CliAction::InspectSession { target } => inspect_session(&target),
CliAction::ListSessions { query, limit } => list_sessions(query.as_deref(), limit), CliAction::ListSessions { query, limit } => list_sessions(query.as_deref(), limit),
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?, CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
CliAction::Repl { model } => run_repl(model)?, CliAction::Repl { model } => run_repl(model)?,
@@ -71,6 +72,9 @@ enum CliAction {
target: String, target: String,
command: Option<String>, command: Option<String>,
}, },
InspectSession {
target: String,
},
ListSessions { ListSessions {
query: Option<String>, query: Option<String>,
limit: usize, limit: usize,
@@ -124,6 +128,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"dump-manifests" => Ok(CliAction::DumpManifests), "dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan), "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"resume" => parse_named_resume_args(&rest[1..]), "resume" => parse_named_resume_args(&rest[1..]),
"session" => parse_session_inspect_args(&rest[1..]),
"sessions" => parse_sessions_args(&rest[1..]), "sessions" => parse_sessions_args(&rest[1..]),
"system-prompt" => parse_system_prompt_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..]),
"prompt" => { "prompt" => {
@@ -177,6 +182,17 @@ fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
Ok(CliAction::ResumeNamed { target, command }) Ok(CliAction::ResumeNamed { target, command })
} }
fn parse_session_inspect_args(args: &[String]) -> Result<CliAction, String> {
let target = args
.first()
.ok_or_else(|| "missing session id, path, or 'latest' for session".to_string())?
.clone();
if args.len() > 1 {
return Err("session accepts exactly one target argument".to_string());
}
Ok(CliAction::InspectSession { target })
}
fn parse_sessions_args(args: &[String]) -> Result<CliAction, String> { fn parse_sessions_args(args: &[String]) -> Result<CliAction, String> {
let mut query = None; let mut query = None;
let mut limit = DEFAULT_SESSION_LIMIT; let mut limit = DEFAULT_SESSION_LIMIT;
@@ -333,6 +349,53 @@ fn list_sessions(query: Option<&str>, limit: usize) {
} }
} }
fn inspect_session(target: &str) {
let path = match resolve_session_target(target) {
Ok(path) => path,
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
};
let session = match Session::load_from_path(&path) {
Ok(session) => session,
Err(error) => {
eprintln!("failed to load session: {error}");
std::process::exit(1);
}
};
let metadata = fs::metadata(&path).ok();
let updated_unix = metadata
.as_ref()
.and_then(|meta| meta.modified().ok())
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map_or(0, |duration| duration.as_secs());
let bytes = metadata.as_ref().map_or(0, std::fs::Metadata::len);
let usage = runtime::UsageTracker::from_session(&session).cumulative_usage();
println!("Session details:");
println!(
"- id: {}",
path.file_stem()
.map_or_else(String::new, |stem| stem.to_string_lossy().into_owned())
);
println!("- path: {}", path.display());
println!("- updated: {updated_unix}");
println!("- size_bytes: {bytes}");
println!("- messages: {}", session.messages.len());
println!("- total_tokens: {}", usage.total_tokens());
println!("- preview: {}", session_preview(&session));
if let Some(user_text) = latest_text_for_role(&session, MessageRole::User) {
println!("- latest_user: {user_text}");
}
if let Some(assistant_text) = latest_text_for_role(&session, MessageRole::Assistant) {
println!("- latest_assistant: {assistant_text}");
}
}
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> { fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true)?; let mut cli = LiveCli::new(model, true)?;
let editor = input::LineEditor::new(" "); let editor = input::LineEditor::new(" ");
@@ -647,6 +710,21 @@ fn session_preview(session: &Session) -> String {
"No text preview available".to_string() "No text preview available".to_string()
} }
fn latest_text_for_role(session: &Session, role: MessageRole) -> Option<String> {
session.messages.iter().rev().find_map(|message| {
if message.role != role {
return None;
}
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| truncate_preview(trimmed, 120))
}
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => None,
})
})
}
fn truncate_preview(text: &str, max_chars: usize) -> String { fn truncate_preview(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars { if text.chars().count() <= max_chars {
return text.to_string(); return text.to_string();
@@ -1033,6 +1111,7 @@ fn print_help() {
println!(" rusty-claude-cli dump-manifests"); println!(" rusty-claude-cli dump-manifests");
println!(" rusty-claude-cli bootstrap-plan"); println!(" rusty-claude-cli bootstrap-plan");
println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]"); println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]");
println!(" rusty-claude-cli session <latest|SESSION|PATH>");
println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]"); println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]");
println!(" env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval"); println!(" env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval");
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
@@ -1107,6 +1186,17 @@ mod tests {
); );
} }
#[test]
fn parses_session_inspect_subcommand() {
let args = vec!["session".to_string(), "latest".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::InspectSession {
target: "latest".to_string(),
}
);
}
#[test] #[test]
fn parses_sessions_subcommand() { fn parses_sessions_subcommand() {
let args = vec![ let args = vec![