From 03caf03a7285b4405f14f6512627f280c2061b53 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 31 Mar 2026 23:25:30 -0700 Subject: [PATCH] feat(claudbg-6m2c): add in/out/tokens filter fields for token-count filtering Add `in:`, `out:`, and `tokens:` filter fields (with `:`, `>`, `<` operators) to the TUI filter query language. Populate input_tokens and output_tokens on SessionListItem from the DB sessions table, wire them into the SessionRow trait, and update the help modal to document the new fields. Co-Authored-By: Claude Sonnet 4.6 --- ...2c--tuifilter-token-count-filter-fields.md | 5 +- src/commands/agents.rs | 6 + src/commands/sessions.rs | 6 + src/filter.rs | 126 +++++++++++++++--- src/tui/modals/help_modal.rs | 15 ++- src/tui/run.rs | 94 +++++++------ src/tui/screens/session_list.rs | 2 + src/tui/state.rs | 8 ++ 8 files changed, 205 insertions(+), 57 deletions(-) diff --git a/.beans/claudbg-6m2c--tuifilter-token-count-filter-fields.md b/.beans/claudbg-6m2c--tuifilter-token-count-filter-fields.md index 144e5a5..480eb93 100644 --- a/.beans/claudbg-6m2c--tuifilter-token-count-filter-fields.md +++ b/.beans/claudbg-6m2c--tuifilter-token-count-filter-fields.md @@ -1,10 +1,11 @@ --- # claudbg-6m2c title: 'TUI/filter: token count filter fields' -status: todo +status: in-progress type: feature +priority: normal created_at: 2026-04-01T06:10:58Z -updated_at: 2026-04-01T06:10:58Z +updated_at: 2026-04-01T06:19:37Z parent: claudbg-2vwx --- diff --git a/src/commands/agents.rs b/src/commands/agents.rs index e8ea396..ebe1d3f 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -181,6 +181,12 @@ impl crate::filter::SessionRow for AgentRowRef<'_> { fn messages(&self) -> u64 { 0 } + fn input_tokens(&self) -> Option { + None + } + fn output_tokens(&self) -> Option { + None + } fn tokens(&self) -> Option { None } diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 89599ed..dc5a132 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -196,6 +196,12 @@ impl crate::filter::SessionRow for RawSessionRow { fn messages(&self) -> u64 { self.message_count as u64 } + fn input_tokens(&self) -> Option { + None + } + fn output_tokens(&self) -> Option { + None + } fn tokens(&self) -> Option { None } diff --git a/src/filter.rs b/src/filter.rs index 2ad9015..af6025a 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -20,14 +20,10 @@ //! | `id` | string | `:` (substring) | //! | `agents` | numeric | `:`, `>`, `<` | //! | `messages` | numeric | `:`, `>`, `<` | +//! | `in` | numeric | `:`, `>`, `<` | +//! | `out` | numeric | `:`, `>`, `<` | //! | `tokens` | numeric | `:`, `>`, `<` | //! | `date` | date | `:`, `>`, `<` | -//! -//! # Not-yet-wired fields -//! -//! - `tokens` — the [`SessionRow`] data available from [`crate::tui::state::SessionListItem`] -//! does not yet carry a total token count; `tokens:` / `tokens>` / `tokens<` filters will -//! always return `false` until the field is added to `SessionListItem`. use chrono::NaiveDate; @@ -52,9 +48,17 @@ pub trait SessionRow { fn agents(&self) -> u64; /// Total message count (all roles). fn messages(&self) -> u64; - /// Total token count. + /// Input (prompt) token count. /// /// Returns `None` when the token count is not available for this row type. + fn input_tokens(&self) -> Option; + /// Output (completion) token count. + /// + /// Returns `None` when the token count is not available for this row type. + fn output_tokens(&self) -> Option; + /// Total token count (input + output). + /// + /// Returns `None` when token counts are not available for this row type. fn tokens(&self) -> Option; /// Session date. /// @@ -78,9 +82,14 @@ impl SessionRow for crate::tui::state::SessionListItem { fn messages(&self) -> u64 { self.msg_count as u64 } - /// Token count is not yet tracked in `SessionListItem`; always returns `None`. + fn input_tokens(&self) -> Option { + Some(self.input_tokens) + } + fn output_tokens(&self) -> Option { + Some(self.output_tokens) + } fn tokens(&self) -> Option { - None + Some(self.input_tokens + self.output_tokens) } fn date(&self) -> Option { // `date` field is e.g. "2026-03-30 14:22:01"; parse just the date portion. @@ -113,6 +122,11 @@ enum Key { Id, Agents, Messages, + /// Input (prompt) token count — `in:`, `in>`, `in<`. + In, + /// Output (completion) token count — `out:`, `out>`, `out<`. + Out, + /// Total token count (input + output) — `tokens:`, `tokens>`, `tokens<`. Tokens, Date, } @@ -125,6 +139,8 @@ impl Key { "id" => Some(Key::Id), "agents" => Some(Key::Agents), "messages" => Some(Key::Messages), + "in" => Some(Key::In), + "out" => Some(Key::Out), "tokens" => Some(Key::Tokens), "date" => Some(Key::Date), _ => None, @@ -214,7 +230,7 @@ fn parse_atom(atom: &str) -> Result { let key = Key::parse(key_str).ok_or_else(|| { AppError::Parse(format!( - "unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, tokens, date" + "unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, in, out, tokens, date" )) })?; @@ -396,6 +412,14 @@ fn eval_pred(pred: &Predicate, row: &R) -> bool { // ── Numeric keys ──────────────────────────────────────────────────── Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value), Key::Messages => eval_numeric(row.messages(), &pred.op, &pred.value), + Key::In => match row.input_tokens() { + Some(v) => eval_numeric(v, &pred.op, &pred.value), + None => false, + }, + Key::Out => match row.output_tokens() { + Some(v) => eval_numeric(v, &pred.op, &pred.value), + None => false, + }, Key::Tokens => match row.tokens() { Some(v) => eval_numeric(v, &pred.op, &pred.value), // Token count not available for this row type — filter does not match. @@ -479,6 +503,23 @@ mod tests { model: model.to_string(), msg_count, agent_count, + input_tokens: 0, + output_tokens: 0, + } + } + + /// Build a row with the given token counts; all other fields use fixed defaults. + fn make_row_with_tokens(full_id: &str, input_tokens: u64, output_tokens: u64) -> SessionListItem { + SessionListItem { + short_id: full_id.get(..8).unwrap_or(full_id).to_string(), + full_id: full_id.to_string(), + date: "2026-03-30 10:00:00".to_string(), + project: "/proj".to_string(), + model: "model".to_string(), + msg_count: 5, + agent_count: 0, + input_tokens, + output_tokens, } } @@ -979,12 +1020,67 @@ mod tests { assert!(f.matches(&haiku_agents)); } - // ── Tokens not available ───────────────────────────────────────────────── + // ── Token filter fields ────────────────────────────────────────────────── #[test] - fn tokens_filter_returns_false_for_session_list_item() { - // SessionListItem does not track token count; tokens filter always false. - let f = Filter::parse("tokens>100").unwrap(); + fn tokens_gt_matches_when_total_exceeds_threshold() { + let f = Filter::parse("tokens>50000").unwrap(); + let big = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 40000, 20000); + let small = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 1000, 500); + assert!(f.matches(&big)); // 60000 > 50000 + assert!(!f.matches(&small)); // 1500 is not > 50000 + } + + #[test] + fn tokens_lt_matches_when_total_below_threshold() { + let f = Filter::parse("tokens<10000").unwrap(); + let small = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 3000, 2000); + let big = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 20000, 5000); + assert!(f.matches(&small)); // 5000 < 10000 + assert!(!f.matches(&big)); // 25000 is not < 10000 + } + + #[test] + fn tokens_colon_equality() { + let f = Filter::parse("tokens:1000").unwrap(); + let exact = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 700, 300); + let other = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 700, 400); + assert!(f.matches(&exact)); // 700+300 == 1000 + assert!(!f.matches(&other)); // 1100 != 1000 + } + + #[test] + fn in_gt_matches_input_tokens() { + let f = Filter::parse("in>5000").unwrap(); + let high = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 10000, 1000); + let low = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 500, 1000); + assert!(f.matches(&high)); // 10000 > 5000 + assert!(!f.matches(&low)); // 500 is not > 5000 + } + + #[test] + fn out_lt_matches_output_tokens() { + let f = Filter::parse("out<1000").unwrap(); + let low_out = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 5000, 200); + let high_out = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 5000, 5000); + assert!(f.matches(&low_out)); // 200 < 1000 + assert!(!f.matches(&high_out)); // 5000 is not < 1000 + } + + #[test] + fn in_and_out_combined() { + // Large input AND small output — very "reading-heavy" sessions. + let f = Filter::parse("in>10000 AND out<500").unwrap(); + let reading_heavy = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 50000, 200); + let balanced = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 50000, 2000); + assert!(f.matches(&reading_heavy)); + assert!(!f.matches(&balanced)); + } + + #[test] + fn tokens_zero_default_with_colon_zero() { + // make_row defaults tokens to 0; tokens:0 should match. + let f = Filter::parse("tokens:0").unwrap(); let row = make_row( "model", "/proj", @@ -993,6 +1089,6 @@ mod tests { 0, "2026-03-30 10:00:00", ); - assert!(!f.matches(&row)); + assert!(f.matches(&row)); } } diff --git a/src/tui/modals/help_modal.rs b/src/tui/modals/help_modal.rs index 036a48f..d512b76 100644 --- a/src/tui/modals/help_modal.rs +++ b/src/tui/modals/help_modal.rs @@ -15,8 +15,8 @@ use crate::tui::state::AppState; // --------------------------------------------------------------------------- /// Dialog dimensions. -const DIALOG_WIDTH: u16 = 32; -const DIALOG_HEIGHT: u16 = 28; +const DIALOG_WIDTH: u16 = 36; +const DIALOG_HEIGHT: u16 = 36; /// Compute a centered [`Rect`] of the given size within `area`. fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -50,6 +50,17 @@ const HELP_TEXT: &str = "\ t / / open filter\n\ Enter apply & close\n\ Esc clear input\n\ +\n\ + Filter fields\n\ + model:haiku substring\n\ + project:foo substring\n\ + id:abc substring\n\ + agents>0 numeric\n\ + messages<50 numeric\n\ + in>5000 input tokens\n\ + out<1000 output tokens\n\ + tokens>50000 total tokens\n\ + date>2026-01 date\n\ \n\ Search (transcript)\n\ t / / open search\n\ diff --git a/src/tui/run.rs b/src/tui/run.rs index c3d0550..7cba356 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -219,52 +219,68 @@ pub fn run_tui() -> Result<()> { model: String::new(), msg_count: 0, agent_count, + input_tokens: 0, + output_tokens: 0, } }) .collect(); - // Enrich session list with data from the DB cache (message counts and - // fallback project paths for sessions whose JSONL first line has no cwd). + // Enrich session list with data from the DB cache (message counts, + // token counts, and fallback project paths for sessions whose JSONL + // first line has no cwd). // Best-effort: silently skip if the DB is missing or unreadable. - let db_enrichment: HashMap = tokio::task::block_in_place( - || { + let db_enrichment: HashMap = + tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - let db_path = crate::db::connection::default_db_path(); - let db = match crate::db::connection::open_db(&db_path, false).await { - Ok(d) => d, - Err(_) => return HashMap::new(), - }; - let conn = match db.connect() { - Ok(c) => c, - Err(_) => return HashMap::new(), - }; - let mut rows = match conn - .query( - "SELECT session_id, COALESCE(project_path, ''), message_count, COALESCE(model, '') FROM sessions", - (), - ) - .await - { - Ok(r) => r, - Err(_) => return HashMap::new(), - }; - let mut map: HashMap = HashMap::new(); - while let Ok(Some(row)) = rows.next().await { - let sid: String = match row.get(0) { - Ok(v) => v, - Err(_) => continue, + let db_path = crate::db::connection::default_db_path(); + let db = match crate::db::connection::open_db(&db_path, false).await { + Ok(d) => d, + Err(_) => return HashMap::new(), }; - let project: String = row.get(1).unwrap_or_default(); - let count: i64 = row.get(2).unwrap_or(0); - let model: String = row.get(3).unwrap_or_default(); - map.insert(sid, (project, count as usize, model)); - } - map - }) - }, - ); + let conn = match db.connect() { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let mut rows = match conn + .query( + "SELECT session_id, COALESCE(project_path, ''), message_count, \ + COALESCE(model, ''), input_tokens, output_tokens FROM sessions", + (), + ) + .await + { + Ok(r) => r, + Err(_) => return HashMap::new(), + }; + let mut map: HashMap = HashMap::new(); + while let Ok(Some(row)) = rows.next().await { + let sid: String = match row.get(0) { + Ok(v) => v, + Err(_) => continue, + }; + let project: String = row.get(1).unwrap_or_default(); + let count: i64 = row.get(2).unwrap_or(0); + let model: String = row.get(3).unwrap_or_default(); + let input_tokens: i64 = row.get(4).unwrap_or(0); + let output_tokens: i64 = row.get(5).unwrap_or(0); + map.insert( + sid, + ( + project, + count as usize, + model, + input_tokens.max(0) as u64, + output_tokens.max(0) as u64, + ), + ); + } + map + }) + }); for item in &mut state.sessions { - if let Some((db_project, db_count, db_model)) = db_enrichment.get(&item.full_id) { + if let Some((db_project, db_count, db_model, db_input, db_output)) = + db_enrichment.get(&item.full_id) + { if item.project.is_empty() && !db_project.is_empty() { item.project = db_project.clone(); } @@ -272,6 +288,8 @@ pub fn run_tui() -> Result<()> { if item.model.is_empty() && !db_model.is_empty() { item.model = db_model.clone(); } + item.input_tokens = *db_input; + item.output_tokens = *db_output; } } diff --git a/src/tui/screens/session_list.rs b/src/tui/screens/session_list.rs index 8b6749b..17d634b 100644 --- a/src/tui/screens/session_list.rs +++ b/src/tui/screens/session_list.rs @@ -386,6 +386,8 @@ mod tests { model: "claude-sonnet-4-6".to_string(), msg_count: 10, agent_count: 2, + input_tokens: 0, + output_tokens: 0, } } diff --git a/src/tui/state.rs b/src/tui/state.rs index a2302fb..a7d7c3a 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -38,6 +38,12 @@ pub struct SessionListItem { pub msg_count: usize, /// Number of sub-agent runs attached to this session. pub agent_count: usize, + /// Total input (prompt) tokens consumed by the session. + /// Zero when not yet fetched from the DB. + pub input_tokens: u64, + /// Total output (completion) tokens produced by the session. + /// Zero when not yet fetched from the DB. + pub output_tokens: u64, } // --------------------------------------------------------------------------- @@ -468,6 +474,8 @@ mod tests { model: "claude-sonnet-4-6".to_string(), msg_count: 10, agent_count: 2, + input_tokens: 0, + output_tokens: 0, }; let cloned = item.clone(); assert_eq!(cloned.short_id, item.short_id);