feat(claudbg-6m2c): add in/out/tokens filter fields for token-count filtering

Add `in:<N>`, `out:<N>`, and `tokens:<N>` 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 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent ae81563983
commit 03caf03a72

@ -1,10 +1,11 @@
--- ---
# claudbg-6m2c # claudbg-6m2c
title: 'TUI/filter: token count filter fields' title: 'TUI/filter: token count filter fields'
status: todo status: in-progress
type: feature type: feature
priority: normal
created_at: 2026-04-01T06:10:58Z created_at: 2026-04-01T06:10:58Z
updated_at: 2026-04-01T06:10:58Z updated_at: 2026-04-01T06:19:37Z
parent: claudbg-2vwx parent: claudbg-2vwx
--- ---

@ -181,6 +181,12 @@ impl crate::filter::SessionRow for AgentRowRef<'_> {
fn messages(&self) -> u64 { fn messages(&self) -> u64 {
0 0
} }
fn input_tokens(&self) -> Option<u64> {
None
}
fn output_tokens(&self) -> Option<u64> {
None
}
fn tokens(&self) -> Option<u64> { fn tokens(&self) -> Option<u64> {
None None
} }

@ -196,6 +196,12 @@ impl crate::filter::SessionRow for RawSessionRow {
fn messages(&self) -> u64 { fn messages(&self) -> u64 {
self.message_count as u64 self.message_count as u64
} }
fn input_tokens(&self) -> Option<u64> {
None
}
fn output_tokens(&self) -> Option<u64> {
None
}
fn tokens(&self) -> Option<u64> { fn tokens(&self) -> Option<u64> {
None None
} }

@ -20,14 +20,10 @@
//! | `id` | string | `:` (substring) | //! | `id` | string | `:` (substring) |
//! | `agents` | numeric | `:`, `>`, `<` | //! | `agents` | numeric | `:`, `>`, `<` |
//! | `messages` | numeric | `:`, `>`, `<` | //! | `messages` | numeric | `:`, `>`, `<` |
//! | `in` | numeric | `:`, `>`, `<` |
//! | `out` | numeric | `:`, `>`, `<` |
//! | `tokens` | numeric | `:`, `>`, `<` | //! | `tokens` | numeric | `:`, `>`, `<` |
//! | `date` | date | `:`, `>`, `<` | //! | `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; use chrono::NaiveDate;
@ -52,9 +48,17 @@ pub trait SessionRow {
fn agents(&self) -> u64; fn agents(&self) -> u64;
/// Total message count (all roles). /// Total message count (all roles).
fn messages(&self) -> u64; fn messages(&self) -> u64;
/// Total token count. /// Input (prompt) token count.
/// ///
/// Returns `None` when the token count is not available for this row type. /// Returns `None` when the token count is not available for this row type.
fn input_tokens(&self) -> Option<u64>;
/// Output (completion) token count.
///
/// Returns `None` when the token count is not available for this row type.
fn output_tokens(&self) -> Option<u64>;
/// Total token count (input + output).
///
/// Returns `None` when token counts are not available for this row type.
fn tokens(&self) -> Option<u64>; fn tokens(&self) -> Option<u64>;
/// Session date. /// Session date.
/// ///
@ -78,9 +82,14 @@ impl SessionRow for crate::tui::state::SessionListItem {
fn messages(&self) -> u64 { fn messages(&self) -> u64 {
self.msg_count as u64 self.msg_count as u64
} }
/// Token count is not yet tracked in `SessionListItem`; always returns `None`. fn input_tokens(&self) -> Option<u64> {
Some(self.input_tokens)
}
fn output_tokens(&self) -> Option<u64> {
Some(self.output_tokens)
}
fn tokens(&self) -> Option<u64> { fn tokens(&self) -> Option<u64> {
None Some(self.input_tokens + self.output_tokens)
} }
fn date(&self) -> Option<NaiveDate> { fn date(&self) -> Option<NaiveDate> {
// `date` field is e.g. "2026-03-30 14:22:01"; parse just the date portion. // `date` field is e.g. "2026-03-30 14:22:01"; parse just the date portion.
@ -113,6 +122,11 @@ enum Key {
Id, Id,
Agents, Agents,
Messages, 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, Tokens,
Date, Date,
} }
@ -125,6 +139,8 @@ impl Key {
"id" => Some(Key::Id), "id" => Some(Key::Id),
"agents" => Some(Key::Agents), "agents" => Some(Key::Agents),
"messages" => Some(Key::Messages), "messages" => Some(Key::Messages),
"in" => Some(Key::In),
"out" => Some(Key::Out),
"tokens" => Some(Key::Tokens), "tokens" => Some(Key::Tokens),
"date" => Some(Key::Date), "date" => Some(Key::Date),
_ => None, _ => None,
@ -214,7 +230,7 @@ fn parse_atom(atom: &str) -> Result<Predicate> {
let key = Key::parse(key_str).ok_or_else(|| { let key = Key::parse(key_str).ok_or_else(|| {
AppError::Parse(format!( 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<R: SessionRow>(pred: &Predicate, row: &R) -> bool {
// ── Numeric keys ──────────────────────────────────────────────────── // ── Numeric keys ────────────────────────────────────────────────────
Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value), Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value),
Key::Messages => eval_numeric(row.messages(), &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() { Key::Tokens => match row.tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value), Some(v) => eval_numeric(v, &pred.op, &pred.value),
// Token count not available for this row type — filter does not match. // Token count not available for this row type — filter does not match.
@ -479,6 +503,23 @@ mod tests {
model: model.to_string(), model: model.to_string(),
msg_count, msg_count,
agent_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)); assert!(f.matches(&haiku_agents));
} }
// ── Tokens not available ───────────────────────────────────────────────── // ── Token filter fields ──────────────────────────────────────────────────
#[test] #[test]
fn tokens_filter_returns_false_for_session_list_item() { fn tokens_gt_matches_when_total_exceeds_threshold() {
// SessionListItem does not track token count; tokens filter always false. let f = Filter::parse("tokens>50000").unwrap();
let f = Filter::parse("tokens>100").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( let row = make_row(
"model", "model",
"/proj", "/proj",
@ -993,6 +1089,6 @@ mod tests {
0, 0,
"2026-03-30 10:00:00", "2026-03-30 10:00:00",
); );
assert!(!f.matches(&row)); assert!(f.matches(&row));
} }
} }

@ -15,8 +15,8 @@ use crate::tui::state::AppState;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Dialog dimensions. /// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32; const DIALOG_WIDTH: u16 = 36;
const DIALOG_HEIGHT: u16 = 28; const DIALOG_HEIGHT: u16 = 36;
/// Compute a centered [`Rect`] of the given size within `area`. /// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -50,6 +50,17 @@ const HELP_TEXT: &str = "\
t / / open filter\n\ t / / open filter\n\
Enter apply & close\n\ Enter apply & close\n\
Esc clear input\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\ \n\
Search (transcript)\n\ Search (transcript)\n\
t / / open search\n\ t / / open search\n\

@ -219,15 +219,18 @@ pub fn run_tui() -> Result<()> {
model: String::new(), model: String::new(),
msg_count: 0, msg_count: 0,
agent_count, agent_count,
input_tokens: 0,
output_tokens: 0,
} }
}) })
.collect(); .collect();
// Enrich session list with data from the DB cache (message counts and // Enrich session list with data from the DB cache (message counts,
// fallback project paths for sessions whose JSONL first line has no cwd). // 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. // Best-effort: silently skip if the DB is missing or unreadable.
let db_enrichment: HashMap<String, (String, usize, String)> = tokio::task::block_in_place( let db_enrichment: HashMap<String, (String, usize, String, u64, u64)> =
|| { tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async { tokio::runtime::Handle::current().block_on(async {
let db_path = crate::db::connection::default_db_path(); let db_path = crate::db::connection::default_db_path();
let db = match crate::db::connection::open_db(&db_path, false).await { let db = match crate::db::connection::open_db(&db_path, false).await {
@ -240,7 +243,8 @@ pub fn run_tui() -> Result<()> {
}; };
let mut rows = match conn let mut rows = match conn
.query( .query(
"SELECT session_id, COALESCE(project_path, ''), message_count, COALESCE(model, '') FROM sessions", "SELECT session_id, COALESCE(project_path, ''), message_count, \
COALESCE(model, ''), input_tokens, output_tokens FROM sessions",
(), (),
) )
.await .await
@ -248,7 +252,7 @@ pub fn run_tui() -> Result<()> {
Ok(r) => r, Ok(r) => r,
Err(_) => return HashMap::new(), Err(_) => return HashMap::new(),
}; };
let mut map: HashMap<String, (String, usize, String)> = HashMap::new(); let mut map: HashMap<String, (String, usize, String, u64, u64)> = HashMap::new();
while let Ok(Some(row)) = rows.next().await { while let Ok(Some(row)) = rows.next().await {
let sid: String = match row.get(0) { let sid: String = match row.get(0) {
Ok(v) => v, Ok(v) => v,
@ -257,14 +261,26 @@ pub fn run_tui() -> Result<()> {
let project: String = row.get(1).unwrap_or_default(); let project: String = row.get(1).unwrap_or_default();
let count: i64 = row.get(2).unwrap_or(0); let count: i64 = row.get(2).unwrap_or(0);
let model: String = row.get(3).unwrap_or_default(); let model: String = row.get(3).unwrap_or_default();
map.insert(sid, (project, count as usize, model)); 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 map
}) })
}, });
);
for item in &mut state.sessions { 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() { if item.project.is_empty() && !db_project.is_empty() {
item.project = db_project.clone(); item.project = db_project.clone();
} }
@ -272,6 +288,8 @@ pub fn run_tui() -> Result<()> {
if item.model.is_empty() && !db_model.is_empty() { if item.model.is_empty() && !db_model.is_empty() {
item.model = db_model.clone(); item.model = db_model.clone();
} }
item.input_tokens = *db_input;
item.output_tokens = *db_output;
} }
} }

@ -386,6 +386,8 @@ mod tests {
model: "claude-sonnet-4-6".to_string(), model: "claude-sonnet-4-6".to_string(),
msg_count: 10, msg_count: 10,
agent_count: 2, agent_count: 2,
input_tokens: 0,
output_tokens: 0,
} }
} }

@ -38,6 +38,12 @@ pub struct SessionListItem {
pub msg_count: usize, pub msg_count: usize,
/// Number of sub-agent runs attached to this session. /// Number of sub-agent runs attached to this session.
pub agent_count: usize, 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(), model: "claude-sonnet-4-6".to_string(),
msg_count: 10, msg_count: 10,
agent_count: 2, agent_count: 2,
input_tokens: 0,
output_tokens: 0,
}; };
let cloned = item.clone(); let cloned = item.clone();
assert_eq!(cloned.short_id, item.short_id); assert_eq!(cloned.short_id, item.short_id);

Loading…
Cancel
Save