Browse Source

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
Yeachan-Heo 2 months ago
parent
commit
add5513ac5
1 changed files with 90 additions and 0 deletions
  1. 90 0
      rust/crates/rusty-claude-cli/src/main.rs

+ 90 - 0
rust/crates/rusty-claude-cli/src/main.rs

@@ -47,6 +47,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             command,
         } => resume_session(&session_path, 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::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
         CliAction::Repl { model } => run_repl(model)?,
@@ -71,6 +72,9 @@ enum CliAction {
         target: String,
         command: Option<String>,
     },
+    InspectSession {
+        target: String,
+    },
     ListSessions {
         query: Option<String>,
         limit: usize,
@@ -124,6 +128,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
         "dump-manifests" => Ok(CliAction::DumpManifests),
         "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
         "resume" => parse_named_resume_args(&rest[1..]),
+        "session" => parse_session_inspect_args(&rest[1..]),
         "sessions" => parse_sessions_args(&rest[1..]),
         "system-prompt" => parse_system_prompt_args(&rest[1..]),
         "prompt" => {
@@ -177,6 +182,17 @@ fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
     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> {
     let mut query = None;
     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>> {
     let mut cli = LiveCli::new(model, true)?;
     let editor = input::LineEditor::new("› ");
@@ -647,6 +710,21 @@ fn session_preview(session: &Session) -> 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 {
     if text.chars().count() <= max_chars {
         return text.to_string();
@@ -1033,6 +1111,7 @@ fn print_help() {
     println!("  rusty-claude-cli dump-manifests");
     println!("  rusty-claude-cli bootstrap-plan");
     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!("  env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval");
     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]
     fn parses_sessions_subcommand() {
         let args = vec![