From 403762400e49b2679afd83385287353c2acfa667 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 31 Mar 2026 10:12:17 -0700 Subject: [PATCH] feat(claudbg-horp): add --filter flag to sessions/agents list commands Co-Authored-By: Claude Sonnet 4.6 --- ...r-flag-on-sessions-list-and-agents-list.md | 32 +++++ src/cli.rs | 8 ++ src/commands/agents.rs | 59 +++++++- src/commands/sessions.rs | 127 ++++++++++++++---- src/main.rs | 8 +- 5 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 .beans/claudbg-horp--cli-filter-flag-on-sessions-list-and-agents-list.md diff --git a/.beans/claudbg-horp--cli-filter-flag-on-sessions-list-and-agents-list.md b/.beans/claudbg-horp--cli-filter-flag-on-sessions-list-and-agents-list.md new file mode 100644 index 0000000..d4066c6 --- /dev/null +++ b/.beans/claudbg-horp--cli-filter-flag-on-sessions-list-and-agents-list.md @@ -0,0 +1,32 @@ +--- +# claudbg-horp +title: 'CLI: --filter flag on sessions list and agents list' +status: completed +type: task +priority: normal +created_at: 2026-03-31T00:33:14Z +updated_at: 2026-03-31T05:14:11Z +parent: claudbg-2vwx +blocked_by: + - claudbg-4bms +--- + +Add `--filter ` flag to `sessions list` and `agents list`. The flag may be passed multiple times; multiple values are ANDed together (equivalent to joining with ` AND `). Depends on the filter query parser. + +## Summary of Changes + +Added `--filter ` flag to `sessions list` and `agents list`. + +### Changes + +- **`src/cli.rs`**: Added `filter: Vec` field with `#[arg(long, action = clap::ArgAction::Append)]` to both `SessionsCmd::List` and `AgentsCmd::List`. + +- **`src/main.rs`**: Destructure and pass `filter` from both list variants through to the command functions. Default for the bare `sessions` command (no subcommand) is `vec![]`. + +- **`src/commands/sessions.rs`**: Added private `RawSessionRow` struct implementing `crate::filter::SessionRow`. The `list` function now accepts `filters: Vec`, parses them all up front with `Filter::parse` (returning errors immediately), collects raw DB rows into `RawSessionRow` values, applies all filters (AND semantics), and then converts passing rows to display strings before rendering. + +- **`src/commands/agents.rs`**: Added private `AgentRowRef<'a>` wrapper around `&AgentRef` implementing `crate::filter::SessionRow` (id → agent_id, model → agent_type, date → modified_at, others zero/empty). The `list` function now accepts `filters: Vec`, parses them, and filters the `agents` vec before building display rows. + +- Updated all existing test call sites in `sessions.rs` and `agents.rs` to pass `vec![]` for the new `filters` parameter. + +All 234 tests pass. diff --git a/src/cli.rs b/src/cli.rs index bce3429..82326a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -185,6 +185,10 @@ pub enum SessionsCmd { /// Maximum number of sessions to show, or "all" for no limit. #[arg(long, default_value = "10")] limit: Limit, + /// Filter expression (may be passed multiple times; all filters are ANDed). + /// Example: --filter 'model:haiku' --filter 'agents>0' + #[arg(long, action = clap::ArgAction::Append)] + filter: Vec, }, /// Dump raw messages from a session. Dump { @@ -214,6 +218,10 @@ pub enum AgentsCmd { /// Maximum number of agents to show, or "all" for no limit. #[arg(long, default_value = "10")] limit: Limit, + /// Filter expression (may be passed multiple times; all filters are ANDed). + /// Example: --filter 'id:abc12345' + #[arg(long, action = clap::ArgAction::Append)] + filter: Vec, }, /// Dump raw messages from an agent run. Dump { diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 99d6ef4..2827389 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -151,6 +151,40 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result(&'a crate::parser::discovery::AgentRef); + +impl crate::filter::SessionRow for AgentRowRef<'_> { + fn model(&self) -> &str { + // Expose agent_type via the `model` key so filters like `model:subagent` work. + self.0.agent_type.as_deref().unwrap_or("") + } + fn project(&self) -> &str { + // No meaningful project for an agent row. + "" + } + fn id(&self) -> &str { + &self.0.agent_id + } + fn agents(&self) -> u64 { + 0 + } + fn messages(&self) -> u64 { + 0 + } + fn tokens(&self) -> Option { + None + } + fn date(&self) -> Option { + Some(self.0.modified_at.date_naive()) + } +} + // --------------------------------------------------------------------------- // Public commands // --------------------------------------------------------------------------- @@ -164,7 +198,20 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result Result<()> { +/// The `filters` are ANDed together; each is a filter query string as +/// accepted by [`crate::filter::Filter::parse`]. +pub async fn list( + session_id: &str, + limit: crate::cli::Limit, + filters: Vec, + opts: &crate::cli::GlobalOpts, +) -> Result<()> { + // Parse all filter expressions up front so we can report errors immediately. + let parsed_filters: Vec = filters + .iter() + .map(|s| crate::filter::Filter::parse(s)) + .collect::>>()?; + let sessions = crate::parser::discovery::discover_sessions()?; let session_ref = sessions .iter() @@ -175,6 +222,12 @@ pub async fn list(session_id: &str, limit: crate::cli::Limit, opts: &crate::cli: let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?; + // Apply filters (AND semantics: all filters must match). + let agents: Vec<_> = agents + .into_iter() + .filter(|a| parsed_filters.iter().all(|f| f.matches(&AgentRowRef(a)))) + .collect(); + let rows: Vec> = agents .iter() .map(|a| { @@ -512,7 +565,7 @@ mod tests { // SAFETY: single-threaded test context; no other threads read HOME concurrently. unsafe { std::env::set_var("HOME", dir.path()) }; let opts = default_opts(); - let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), &opts).await; + let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), vec![], &opts).await; assert!( matches!(result, Err(crate::error::AppError::NotFound(_))), "expected NotFound, got: {result:?}" @@ -568,7 +621,7 @@ mod tests { // SAFETY: single-threaded test context; no other threads read HOME concurrently. unsafe { std::env::set_var("HOME", dir.path()) }; let opts = default_opts(); - let result = list("aaaabbbb", crate::cli::Limit::default(), &opts).await; + let result = list("aaaabbbb", crate::cli::Limit::default(), vec![], &opts).await; assert!(result.is_ok(), "expected Ok, got: {result:?}"); } diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 34ce84f..ff6a8db 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -162,13 +162,70 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli } } +// --------------------------------------------------------------------------- +// Helper struct for filter evaluation +// --------------------------------------------------------------------------- + +/// Raw session data used to evaluate filter predicates before building display strings. +struct RawSessionRow { + session_id: String, + project_path: String, + model: String, + last_msg_at: String, + message_count: i64, + agent_count: usize, +} + +impl crate::filter::SessionRow for RawSessionRow { + fn model(&self) -> &str { + &self.model + } + fn project(&self) -> &str { + &self.project_path + } + fn id(&self) -> &str { + &self.session_id + } + fn agents(&self) -> u64 { + self.agent_count as u64 + } + fn messages(&self) -> u64 { + self.message_count as u64 + } + fn tokens(&self) -> Option { + None + } + fn date(&self) -> Option { + // last_msg_at is e.g. "2026-03-30 14:22:01"; parse the date portion. + self.last_msg_at + .get(..10) + .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + } +} + +// --------------------------------------------------------------------------- +// sessions list +// --------------------------------------------------------------------------- + /// Run `sessions list`. /// /// Discovers all session files, syncs them to the DB, then queries the DB /// for a summary sorted most-recent-first. Respects `opts.output` and /// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes). /// The `limit` controls how many rows are returned (default 10, or all). -pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Result<()> { +/// The `filters` are ANDed together; each is a filter query string as +/// accepted by [`crate::filter::Filter::parse`]. +pub async fn list( + limit: crate::cli::Limit, + filters: Vec, + opts: &crate::cli::GlobalOpts, +) -> Result<()> { + // Parse all filter expressions up front so we can report errors immediately. + let parsed_filters: Vec = filters + .iter() + .map(|s| crate::filter::Filter::parse(s)) + .collect::>>()?; + let db_path = crate::db::connection::default_db_path(); let db = crate::db::connection::open_db(&db_path, false).await?; @@ -197,8 +254,8 @@ pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Re .await .map_err(|e| crate::error::AppError::Db(e.to_string()))?; - // Collect rows. - let mut rows: Vec> = Vec::new(); + // Collect raw rows. + let mut raw_rows: Vec = Vec::new(); while let Some(row) = rows_cursor .next() .await @@ -210,41 +267,61 @@ pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Re let last_msg_at: String = row.get(3).unwrap_or_default(); let message_count: i64 = row.get(4).unwrap_or_default(); + // Count sub-agents for this session. + let agent_count = if let Some(file_path) = session_file_map.get(&session_id) { + crate::parser::discovery::discover_agents_for_session(file_path) + .unwrap_or_default() + .len() + } else { + 0 + }; + + raw_rows.push(RawSessionRow { + session_id, + project_path, + model, + last_msg_at, + message_count, + agent_count, + }); + } + + // Apply filters (AND semantics: all filters must match). + let raw_rows: Vec = raw_rows + .into_iter() + .filter(|r| parsed_filters.iter().all(|f| f.matches(r))) + .collect(); + + // Convert to display rows. + let mut rows: Vec> = Vec::new(); + for raw in raw_rows { let display_id = if opts.verbose { - session_id.clone() + raw.session_id.clone() } else { - crate::util::short_id(&session_id).to_string() + crate::util::short_id(&raw.session_id).to_string() }; const MAX_PATH_LEN: usize = 40; - let display_path = if project_path.len() > MAX_PATH_LEN { - let boundary = project_path + let display_path = if raw.project_path.len() > MAX_PATH_LEN { + let boundary = raw + .project_path .char_indices() .rev() .map(|(i, _)| i) .nth(MAX_PATH_LEN - 1) .unwrap_or(0); - format!("…{}", &project_path[boundary..]) + format!("…{}", &raw.project_path[boundary..]) } else { - project_path - }; - - // Count sub-agents for this session. - let agent_count = if let Some(file_path) = session_file_map.get(&session_id) { - crate::parser::discovery::discover_agents_for_session(file_path) - .unwrap_or_default() - .len() - } else { - 0 + raw.project_path }; rows.push(vec![ display_id, - last_msg_at, + raw.last_msg_at, display_path, - model, - message_count.to_string(), - agent_count.to_string(), + raw.model, + raw.message_count.to_string(), + raw.agent_count.to_string(), ]); } @@ -697,7 +774,7 @@ mod tests { // SAFETY: single-threaded test context; no other threads read HOME concurrently. unsafe { std::env::set_var("HOME", dir.path()) }; let opts = default_opts(); - let result = list(crate::cli::Limit::default(), &opts).await; + let result = list(crate::cli::Limit::default(), vec![], &opts).await; assert!(result.is_ok(), "list failed: {:?}", result.err()); } @@ -713,7 +790,7 @@ mod tests { output: OutputFormat::Json, ..default_opts() }; - let result = list(crate::cli::Limit::default(), &opts).await; + let result = list(crate::cli::Limit::default(), vec![], &opts).await; assert!(result.is_ok(), "list json failed: {:?}", result.err()); } @@ -729,7 +806,7 @@ mod tests { output: OutputFormat::Xml, ..default_opts() }; - let result = list(crate::cli::Limit::default(), &opts).await; + let result = list(crate::cli::Limit::default(), vec![], &opts).await; assert!(result.is_ok(), "list xml failed: {:?}", result.err()); } diff --git a/src/main.rs b/src/main.rs index b8082f2..e2dec80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,8 @@ use claudbg::error::Result; async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default() }) { - SessionsCmd::List { limit } => claudbg::commands::sessions::list(limit, &cli.global).await?, + Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] }) { + SessionsCmd::List { limit, filter } => claudbg::commands::sessions::list(limit, filter, &cli.global).await?, SessionsCmd::Dump { id, follow } => { claudbg::commands::sessions::dump(&id, follow, &cli.global).await? } @@ -18,8 +18,8 @@ async fn main() -> Result<()> { } }, Commands::Agents { cmd } => match cmd { - AgentsCmd::List { session_id, limit } => { - claudbg::commands::agents::list(&session_id, limit, &cli.global).await? + AgentsCmd::List { session_id, limit, filter } => { + claudbg::commands::agents::list(&session_id, limit, filter, &cli.global).await? } AgentsCmd::Dump { session_id,