Browse Source

Make Rust sessions easier to find and resume

This adds a lightweight session home for the Rust CLI, auto-persists REPL state, and exposes list, search, show, and named resume flows so users no longer need to remember raw JSON paths.

The change keeps the old --resume SESSION.json path working while adding friendlier session discovery. It also makes API env-based tests hermetic so workspace verification remains stable regardless of shell environment.

Constraint: Keep session UX incremental and CLI-native without introducing a new database or TUI layer

Constraint: Preserve backward compatibility for the existing --resume SESSION.json workflow

Rejected: Build a richer interactive picker now | higher implementation cost than needed for this parity slice

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep human-friendly session lookup additive; do not remove explicit path-based resume support

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

Not-tested: Manual multi-session interactive REPL behavior across multiple terminals
Yeachan-Heo 2 tháng trước cách đây
mục cha
commit
d6a814258c
2 tập tin đã thay đổi với 326 bổ sung2 xóa
  1. 20 0
      rust/crates/api/src/client.rs
  2. 306 2
      rust/crates/rusty-claude-cli/src/main.rs

+ 20 - 0
rust/crates/api/src/client.rs

@@ -311,18 +311,38 @@ mod tests {
 
     #[test]
     fn read_api_key_requires_presence() {
+        let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
+        let previous_key = std::env::var("ANTHROPIC_API_KEY").ok();
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         let error = super::read_api_key().expect_err("missing key should error");
         assert!(matches!(error, crate::error::ApiError::MissingApiKey));
+        match previous_auth {
+            Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
+            None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
+        }
+        match previous_key {
+            Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
+            None => std::env::remove_var("ANTHROPIC_API_KEY"),
+        }
     }
 
     #[test]
     fn read_api_key_requires_non_empty_value() {
+        let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
+        let previous_key = std::env::var("ANTHROPIC_API_KEY").ok();
         std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
         std::env::remove_var("ANTHROPIC_API_KEY");
         let error = super::read_api_key().expect_err("empty key should error");
         assert!(matches!(error, crate::error::ApiError::MissingApiKey));
+        match previous_auth {
+            Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
+            None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
+        }
+        match previous_key {
+            Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
+            None => std::env::remove_var("ANTHROPIC_API_KEY"),
+        }
     }
 
     #[test]

+ 306 - 2
rust/crates/rusty-claude-cli/src/main.rs

@@ -2,8 +2,10 @@ mod input;
 mod render;
 
 use std::env;
+use std::fs;
 use std::io::{self, Write};
 use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
 
 use api::{
     AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
@@ -24,6 +26,7 @@ use tools::{execute_tool, mvp_tool_specs};
 const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
 const DEFAULT_MAX_TOKENS: u32 = 32;
 const DEFAULT_DATE: &str = "2026-03-31";
+const DEFAULT_SESSION_LIMIT: usize = 20;
 
 fn main() {
     if let Err(error) = run() {
@@ -42,6 +45,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             session_path,
             command,
         } => resume_session(&session_path, command),
+        CliAction::ResumeNamed { target, command } => resume_named_session(&target, command),
+        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)?,
         CliAction::Help => print_help(),
@@ -61,6 +66,14 @@ enum CliAction {
         session_path: PathBuf,
         command: Option<String>,
     },
+    ResumeNamed {
+        target: String,
+        command: Option<String>,
+    },
+    ListSessions {
+        query: Option<String>,
+        limit: usize,
+    },
     Prompt {
         prompt: String,
         model: String,
@@ -109,6 +122,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
     match rest[0].as_str() {
         "dump-manifests" => Ok(CliAction::DumpManifests),
         "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
+        "resume" => parse_named_resume_args(&rest[1..]),
+        "sessions" => parse_sessions_args(&rest[1..]),
         "system-prompt" => parse_system_prompt_args(&rest[1..]),
         "prompt" => {
             let prompt = rest[1..].join(" ");
@@ -149,6 +164,48 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
     Ok(CliAction::PrintSystemPrompt { cwd, date })
 }
 
+fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
+    let target = args
+        .first()
+        .ok_or_else(|| "missing session id, path, or 'latest' for resume".to_string())?
+        .clone();
+    let command = args.get(1).cloned();
+    if args.len() > 2 {
+        return Err("resume accepts at most one trailing slash command".to_string());
+    }
+    Ok(CliAction::ResumeNamed { target, command })
+}
+
+fn parse_sessions_args(args: &[String]) -> Result<CliAction, String> {
+    let mut query = None;
+    let mut limit = DEFAULT_SESSION_LIMIT;
+    let mut index = 0;
+
+    while index < args.len() {
+        match args[index].as_str() {
+            "--query" => {
+                let value = args
+                    .get(index + 1)
+                    .ok_or_else(|| "missing value for --query".to_string())?;
+                query = Some(value.clone());
+                index += 2;
+            }
+            "--limit" => {
+                let value = args
+                    .get(index + 1)
+                    .ok_or_else(|| "missing value for --limit".to_string())?;
+                limit = value
+                    .parse::<usize>()
+                    .map_err(|error| format!("invalid --limit value: {error}"))?;
+                index += 2;
+            }
+            other => return Err(format!("unknown sessions option: {other}")),
+        }
+    }
+
+    Ok(CliAction::ListSessions { query, limit })
+}
+
 fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
     let session_path = args
         .first()
@@ -238,6 +295,43 @@ fn resume_session(session_path: &Path, command: Option<String>) {
     }
 }
 
+fn resume_named_session(target: &str, command: Option<String>) {
+    let session_path = match resolve_session_target(target) {
+        Ok(path) => path,
+        Err(error) => {
+            eprintln!("{error}");
+            std::process::exit(1);
+        }
+    };
+    resume_session(&session_path, command);
+}
+
+fn list_sessions(query: Option<&str>, limit: usize) {
+    match load_session_entries(query, limit) {
+        Ok(entries) => {
+            if entries.is_empty() {
+                println!("No saved sessions found.");
+                return;
+            }
+            println!("Saved sessions:");
+            for entry in entries {
+                println!(
+                    "- {} | updated={} | messages={} | tokens={} | {}",
+                    entry.id,
+                    entry.updated_unix,
+                    entry.message_count,
+                    entry.total_tokens,
+                    entry.preview
+                );
+            }
+        }
+        Err(error) => {
+            eprintln!("failed to list sessions: {error}");
+            std::process::exit(1);
+        }
+    }
+}
+
 fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
     let mut cli = LiveCli::new(model, true)?;
     let editor = input::LineEditor::new("› ");
@@ -271,11 +365,13 @@ struct LiveCli {
     model: String,
     system_prompt: Vec<String>,
     runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
+    session_path: PathBuf,
 }
 
 impl LiveCli {
     fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
         let system_prompt = build_system_prompt()?;
+        let session_path = new_session_path()?;
         let runtime = build_runtime(
             Session::new(),
             model.clone(),
@@ -286,6 +382,7 @@ impl LiveCli {
             model,
             system_prompt,
             runtime,
+            session_path,
         })
     }
 
@@ -306,6 +403,7 @@ impl LiveCli {
                     &mut stdout,
                 )?;
                 println!();
+                self.persist_session()?;
                 self.print_turn_usage(turn.usage);
                 Ok(())
             }
@@ -370,10 +468,152 @@ impl LiveCli {
             let estimated_saved = estimated_before.saturating_sub(estimated_after);
             println!("Estimated tokens saved: {estimated_saved}");
         }
+        self.persist_session()?;
+        Ok(())
+    }
+
+    fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
+        self.runtime.session().save_to_path(&self.session_path)?;
         Ok(())
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct SessionListEntry {
+    id: String,
+    path: PathBuf,
+    updated_unix: u64,
+    message_count: usize,
+    total_tokens: u32,
+    preview: String,
+}
+
+fn new_session_path() -> io::Result<PathBuf> {
+    let session_dir = default_session_dir()?;
+    fs::create_dir_all(&session_dir)?;
+    let timestamp = current_unix_timestamp();
+    let process_id = std::process::id();
+    Ok(session_dir.join(format!("session-{timestamp}-{process_id}.json")))
+}
+
+fn default_session_dir() -> io::Result<PathBuf> {
+    Ok(env::current_dir()?.join(".rusty-claude").join("sessions"))
+}
+
+fn current_unix_timestamp() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |duration| duration.as_secs())
+}
+
+fn resolve_session_target(target: &str) -> io::Result<PathBuf> {
+    let direct_path = PathBuf::from(target);
+    if direct_path.is_file() {
+        return Ok(direct_path);
+    }
+
+    let entries = load_session_entries(None, usize::MAX)?;
+    if target == "latest" {
+        return entries
+            .into_iter()
+            .next()
+            .map(|entry| entry.path)
+            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no saved sessions found"));
+    }
+
+    let mut matches = entries
+        .into_iter()
+        .filter(|entry| entry.id.contains(target) || entry.preview.contains(target))
+        .collect::<Vec<_>>();
+    if matches.is_empty() {
+        return Err(io::Error::new(
+            io::ErrorKind::NotFound,
+            format!("no saved session matched '{target}'"),
+        ));
+    }
+    matches.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
+    Ok(matches.remove(0).path)
+}
+
+fn load_session_entries(query: Option<&str>, limit: usize) -> io::Result<Vec<SessionListEntry>> {
+    let session_dir = default_session_dir()?;
+    if !session_dir.exists() {
+        return Ok(Vec::new());
+    }
+
+    let query = query.map(str::to_lowercase);
+    let mut entries = Vec::new();
+    for entry in fs::read_dir(session_dir)? {
+        let entry = entry?;
+        let path = entry.path();
+        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
+            continue;
+        }
+
+        let Ok(session) = Session::load_from_path(&path) else {
+            continue;
+        };
+
+        let preview = session_preview(&session);
+        let id = path
+            .file_stem()
+            .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
+        let searchable = format!("{} {}", id.to_lowercase(), preview.to_lowercase());
+        if let Some(query) = &query {
+            if !searchable.contains(query) {
+                continue;
+            }
+        }
+
+        let updated_unix = entry
+            .metadata()
+            .and_then(|metadata| metadata.modified())
+            .ok()
+            .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
+            .map_or(0, |duration| duration.as_secs());
+
+        entries.push(SessionListEntry {
+            id,
+            path,
+            updated_unix,
+            message_count: session.messages.len(),
+            total_tokens: runtime::UsageTracker::from_session(&session)
+                .cumulative_usage()
+                .total_tokens(),
+            preview,
+        });
+    }
+
+    entries.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
+    if limit < entries.len() {
+        entries.truncate(limit);
+    }
+    Ok(entries)
+}
+
+fn session_preview(session: &Session) -> String {
+    for message in session.messages.iter().rev() {
+        for block in &message.blocks {
+            if let ContentBlock::Text { text } = block {
+                let trimmed = text.trim();
+                if !trimmed.is_empty() {
+                    return truncate_preview(trimmed, 80);
+                }
+            }
+        }
+    }
+    "No text preview available".to_string()
+}
+
+fn truncate_preview(text: &str, max_chars: usize) -> String {
+    if text.chars().count() <= max_chars {
+        return text.to_string();
+    }
+    let mut output = text.chars().take(max_chars).collect::<String>();
+    output.push('…');
+    output
+}
+
 fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
     Ok(load_system_prompt(
         env::current_dir()?,
@@ -671,15 +911,19 @@ 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 resume <latest|SESSION|PATH> [/compact]");
     println!("  rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
     println!("  rusty-claude-cli --resume SESSION.json [/compact]");
 }
 
 #[cfg(test)]
 mod tests {
-    use super::{parse_args, CliAction, DEFAULT_MODEL};
-    use runtime::{ContentBlock, ConversationMessage, MessageRole};
+    use super::{parse_args, resolve_session_target, session_preview, CliAction, DEFAULT_MODEL};
+    use runtime::{ContentBlock, ConversationMessage, MessageRole, Session};
+    use std::fs;
     use std::path::PathBuf;
+    use std::time::{SystemTime, UNIX_EPOCH};
 
     #[test]
     fn defaults_to_repl_when_no_args() {
@@ -741,6 +985,40 @@ mod tests {
         );
     }
 
+    #[test]
+    fn parses_sessions_subcommand() {
+        let args = vec![
+            "sessions".to_string(),
+            "--query".to_string(),
+            "compact".to_string(),
+            "--limit".to_string(),
+            "5".to_string(),
+        ];
+        assert_eq!(
+            parse_args(&args).expect("args should parse"),
+            CliAction::ListSessions {
+                query: Some("compact".to_string()),
+                limit: 5,
+            }
+        );
+    }
+
+    #[test]
+    fn parses_named_resume_subcommand() {
+        let args = vec![
+            "resume".to_string(),
+            "latest".to_string(),
+            "/compact".to_string(),
+        ];
+        assert_eq!(
+            parse_args(&args).expect("args should parse"),
+            CliAction::ResumeNamed {
+                target: "latest".to_string(),
+                command: Some("/compact".to_string()),
+            }
+        );
+    }
+
     #[test]
     fn converts_tool_roundtrip_messages() {
         let messages = vec![
@@ -767,4 +1045,30 @@ mod tests {
         assert_eq!(converted[1].role, "assistant");
         assert_eq!(converted[2].role, "user");
     }
+
+    #[test]
+    fn builds_preview_from_latest_text_block() {
+        let session = Session {
+            version: 1,
+            messages: vec![
+                ConversationMessage::user_text("first"),
+                ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "latest preview".to_string(),
+                }]),
+            ],
+        };
+        assert_eq!(session_preview(&session), "latest preview");
+    }
+
+    #[test]
+    fn resolves_direct_session_path() {
+        let unique = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map_or(0, |duration| duration.as_nanos());
+        let path = std::env::temp_dir().join(format!("rusty-claude-session-{unique}.json"));
+        fs::write(&path, "{\"version\":1,\"messages\":[]}").expect("temp session");
+        let resolved = resolve_session_target(path.to_string_lossy().as_ref()).expect("resolve");
+        assert_eq!(resolved, path);
+        fs::remove_file(resolved).expect("cleanup");
+    }
 }