feat(claudbg-horp): add --filter flag to sessions/agents list commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 277e3a8667
commit 403762400e

@ -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 <query>` 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 <query>` flag to `sessions list` and `agents list`.
### Changes
- **`src/cli.rs`**: Added `filter: Vec<String>` 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<String>`, 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<String>`, 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.

@ -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<String>,
},
/// 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<String>,
},
/// Dump raw messages from an agent run.
Dump {

@ -151,6 +151,40 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
Ok(agent_ref)
}
// ---------------------------------------------------------------------------
// Helper: SessionRow impl for AgentRef
// ---------------------------------------------------------------------------
/// Wrapper around [`crate::parser::discovery::AgentRef`] that implements
/// [`crate::filter::SessionRow`] so filters can be applied to agent list rows.
struct AgentRowRef<'a>(&'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<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
Some(self.0.modified_at.date_naive())
}
}
// ---------------------------------------------------------------------------
// Public commands
// ---------------------------------------------------------------------------
@ -164,7 +198,20 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
/// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and
/// full file paths instead of 8-char prefixes and basenames).
/// The `limit` controls how many rows are returned (default 10, or all).
pub async fn list(session_id: &str, 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(
session_id: &str,
limit: crate::cli::Limit,
filters: Vec<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
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<Vec<String>> = 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:?}");
}

@ -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<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
// 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<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
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<String>> = Vec::new();
// Collect raw rows.
let mut raw_rows: Vec<RawSessionRow> = 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<RawSessionRow> = raw_rows
.into_iter()
.filter(|r| parsed_filters.iter().all(|f| f.matches(r)))
.collect();
// Convert to display rows.
let mut rows: Vec<Vec<String>> = 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());
}

@ -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,

Loading…
Cancel
Save