fix(claudbg-u28r): use blue/white search highlight for light mode legibility

Also close claudbg-4g3l, claudbg-ltt0, claudbg-37cj as already implemented.
Fixes pre-existing fmt issues in agents.rs, sessions.rs, filter.rs, and TUI files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent bb50c0c01d
commit b7c54dd463

@ -1,11 +1,16 @@
--- ---
# claudbg-37cj # claudbg-37cj
title: Add --[no-]color global flag and NO_COLOR env var support title: Add --[no-]color global flag and NO_COLOR env var support
status: todo status: completed
type: task type: task
priority: normal
created_at: 2026-03-31T00:32:57Z created_at: 2026-03-31T00:32:57Z
updated_at: 2026-03-31T00:32:57Z updated_at: 2026-04-01T05:44:59Z
parent: claudbg-qpfe parent: claudbg-qpfe
--- ---
Add `--color` / `--no-color` as a global CLI flag (available on all commands). When not specified, auto-detect: enable color if stdout is a tty and NO_COLOR is unset/empty. Honor the NO_COLOR environment variable (any non-empty value → disable color), per the NO_COLOR spec. This flag controls all ANSI output in the CLI (transcripts etc). Add `--color` / `--no-color` as a global CLI flag (available on all commands). When not specified, auto-detect: enable color if stdout is a tty and NO_COLOR is unset/empty. Honor the NO_COLOR environment variable (any non-empty value → disable color), per the NO_COLOR spec. This flag controls all ANSI output in the CLI (transcripts etc).
## Summary of Changes
Already implemented prior to this bean being created. `GlobalOpts` in `src/cli.rs` has `--color`/`--no-color` flags and `color_enabled()` respects `NO_COLOR` env var per spec.

@ -1,10 +1,15 @@
--- ---
# claudbg-4g3l # claudbg-4g3l
title: Limit list output to 10 by default with --limit flag title: Limit list output to 10 by default with --limit flag
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-03-31T00:32:40Z created_at: 2026-03-31T00:32:40Z
updated_at: 2026-03-31T00:32:40Z updated_at: 2026-04-01T05:44:56Z
--- ---
Both `sessions list` and `agents list <session-id>` should default to showing only the 10 most recent entries (sorted most-recent-first). Add a `--limit=<N|all>` flag to override: accepts an integer (e.g. `--limit=50`) or the keyword `all` to show everything. Both `sessions list` and `agents list <session-id>` should default to showing only the 10 most recent entries (sorted most-recent-first). Add a `--limit=<N|all>` flag to override: accepts an integer (e.g. `--limit=50`) or the keyword `all` to show everything.
## Summary of Changes
Already implemented prior to this bean being created. The `Limit` enum with `Count(10)` default is in `src/cli.rs`, applied via `--limit` flag on both `sessions list` and `agents list`.

@ -1,10 +1,15 @@
--- ---
# claudbg-ltt0 # claudbg-ltt0
title: 'sessions: no sub-command defaults to sessions list' title: 'sessions: no sub-command defaults to sessions list'
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-03-31T00:32:35Z created_at: 2026-03-31T00:32:35Z
updated_at: 2026-03-31T00:32:35Z updated_at: 2026-04-01T05:44:58Z
--- ---
Running `claudbg sessions` with no sub-command should behave identically to `claudbg sessions list`. Note: `claudbg agents` does NOT get this treatment since agents list requires a session ID. Running `claudbg sessions` with no sub-command should behave identically to `claudbg sessions list`. Note: `claudbg agents` does NOT get this treatment since agents list requires a session ID.
## Summary of Changes
Already implemented prior to this bean being created. `src/main.rs` uses `cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] })` so `claudbg sessions` defaults to `sessions list`.

@ -1,10 +1,15 @@
--- ---
# claudbg-u28r # claudbg-u28r
title: 'TUI: search highlight text unreadable in light mode' title: 'TUI: search highlight text unreadable in light mode'
status: todo status: completed
type: bug type: bug
priority: normal
created_at: 2026-03-31T23:45:15Z created_at: 2026-03-31T23:45:15Z
updated_at: 2026-03-31T23:45:15Z updated_at: 2026-04-01T05:45:54Z
--- ---
In light mode, the selected/highlighted search match text is too dark to read against the highlight background. The search highlight colors need to be adjusted to be legible in both light and dark mode. In light mode, the selected/highlighted search match text is too dark to read against the highlight background. The search highlight colors need to be adjusted to be legible in both light and dark mode.
## Summary of Changes
Changed `SEARCH_HIGHLIGHT` style in `src/tui/screens/transcript.rs` from `bg(Color::Yellow).fg(Color::Black)` to `bg(Color::Blue).fg(Color::White)`. Blue/white is universally readable in both light and dark terminal themes.

@ -82,7 +82,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let cap = if opts.verbose { usize::MAX } else { 120 }; let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap); let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; let ellipsis = if !opts.verbose && input_preview.len() > 120 {
"…"
} else {
""
};
let label = crate::output::color::blue(&format!("[tool: {name}]"), color); let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}"); println!("{label} {input_short}{ellipsis}");
} }
@ -565,7 +569,13 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), vec![], &opts).await; let result = list(
"nonexistent-session-id-xyz",
crate::cli::Limit::default(),
vec![],
&opts,
)
.await;
assert!( assert!(
matches!(result, Err(crate::error::AppError::NotFound(_))), matches!(result, Err(crate::error::AppError::NotFound(_))),
"expected NotFound, got: {result:?}" "expected NotFound, got: {result:?}"

@ -121,7 +121,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let cap = if opts.verbose { usize::MAX } else { 120 }; let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap); let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; let ellipsis = if !opts.verbose && input_preview.len() > 120 {
"…"
} else {
""
};
let label = crate::output::color::blue(&format!("[tool: {name}]"), color); let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}"); println!("{label} {input_short}{ellipsis}");
} }
@ -328,12 +332,10 @@ pub async fn list(
let rows = limit.apply(rows); let rows = limit.apply(rows);
let output = match opts.output { let output = match opts.output {
crate::cli::OutputFormat::Table => { crate::cli::OutputFormat::Table => crate::output::render_table(
crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"], &["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows, &rows,
)? )?,
}
crate::cli::OutputFormat::Json => { crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows let objects: Vec<serde_json::Value> = rows
.iter() .iter()
@ -351,7 +353,14 @@ pub async fn list(
crate::output::render_json(&objects)? crate::output::render_json(&objects)?
} }
crate::cli::OutputFormat::Xml => crate::output::render_xml_rows( crate::cli::OutputFormat::Xml => crate::output::render_xml_rows(
&["session_id", "date", "project", "model", "messages", "subagents"], &[
"session_id",
"date",
"project",
"model",
"messages",
"subagents",
],
&rows, &rows,
)?, )?,
}; };

@ -84,7 +84,9 @@ impl SessionRow for crate::tui::state::SessionListItem {
} }
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.
self.date.get(..10).and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) self.date
.get(..10)
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
} }
} }
@ -217,9 +219,7 @@ fn parse_atom(atom: &str) -> Result<Predicate> {
})?; })?;
// For string keys (model/project/id) only `:` is allowed. // For string keys (model/project/id) only `:` is allowed.
if matches!(key, Key::Model | Key::Project | Key::Id) if matches!(key, Key::Model | Key::Project | Key::Id) && !matches!(op, Op::Colon) {
&& !matches!(op, Op::Colon)
{
return Err(AppError::Parse(format!( return Err(AppError::Parse(format!(
"key '{key_str}' only supports ':' operator" "key '{key_str}' only supports ':' operator"
))); )));
@ -622,29 +622,64 @@ mod tests {
#[test] #[test]
fn match_model_substring() { fn match_model_substring() {
let f = Filter::parse("model:haiku").unwrap(); let f = Filter::parse("model:haiku").unwrap();
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row)); assert!(f.matches(&row));
} }
#[test] #[test]
fn match_model_substring_case_insensitive() { fn match_model_substring_case_insensitive() {
let f = Filter::parse("model:HAIKU").unwrap(); let f = Filter::parse("model:HAIKU").unwrap();
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row)); assert!(f.matches(&row));
} }
#[test] #[test]
fn no_match_model_substring() { fn no_match_model_substring() {
let f = Filter::parse("model:sonnet").unwrap(); let f = Filter::parse("model:sonnet").unwrap();
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(!f.matches(&row)); assert!(!f.matches(&row));
} }
#[test] #[test]
fn match_model_wildcard() { fn match_model_wildcard() {
let f = Filter::parse("model:*").unwrap(); let f = Filter::parse("model:*").unwrap();
let row_with_model = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row_with_model = make_row(
let row_empty_model = make_row("", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); "claude-haiku-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let row_empty_model = make_row(
"",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row_with_model)); assert!(f.matches(&row_with_model));
assert!(!f.matches(&row_empty_model)); assert!(!f.matches(&row_empty_model));
} }
@ -652,14 +687,28 @@ mod tests {
#[test] #[test]
fn match_project_substring() { fn match_project_substring() {
let f = Filter::parse("project:my-org").unwrap(); let f = Filter::parse("project:my-org").unwrap();
let row = make_row("claude-sonnet-4-6", "/home/pop/my-org/repo", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"claude-sonnet-4-6",
"/home/pop/my-org/repo",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row)); assert!(f.matches(&row));
} }
#[test] #[test]
fn match_id_substring() { fn match_id_substring() {
let f = Filter::parse("id:aaaaaaaa").unwrap(); let f = Filter::parse("id:aaaaaaaa").unwrap();
let row = make_row("claude-sonnet-4-6", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"claude-sonnet-4-6",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row)); assert!(f.matches(&row));
} }
@ -668,8 +717,22 @@ mod tests {
#[test] #[test]
fn match_agents_gt() { fn match_agents_gt() {
let f = Filter::parse("agents>0").unwrap(); let f = Filter::parse("agents>0").unwrap();
let with_agents = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00"); let with_agents = make_row(
let no_agents = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
let no_agents = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&with_agents)); assert!(f.matches(&with_agents));
assert!(!f.matches(&no_agents)); assert!(!f.matches(&no_agents));
} }
@ -677,8 +740,22 @@ mod tests {
#[test] #[test]
fn match_messages_lt() { fn match_messages_lt() {
let f = Filter::parse("messages<10").unwrap(); let f = Filter::parse("messages<10").unwrap();
let few = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let few = make_row(
let many = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 20, 0, "2026-03-30 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let many = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
20,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&few)); assert!(f.matches(&few));
assert!(!f.matches(&many)); assert!(!f.matches(&many));
} }
@ -686,8 +763,22 @@ mod tests {
#[test] #[test]
fn match_messages_colon_equality() { fn match_messages_colon_equality() {
let f = Filter::parse("messages:5").unwrap(); let f = Filter::parse("messages:5").unwrap();
let five = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let five = make_row(
let six = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 6, 0, "2026-03-30 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let six = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
6,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&five)); assert!(f.matches(&five));
assert!(!f.matches(&six)); assert!(!f.matches(&six));
} }
@ -697,8 +788,22 @@ mod tests {
#[test] #[test]
fn match_date_gt() { fn match_date_gt() {
let f = Filter::parse("date>2026-03-15").unwrap(); let f = Filter::parse("date>2026-03-15").unwrap();
let after = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-20 10:00:00"); let after = make_row(
let before = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-10 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-20 10:00:00",
);
let before = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-10 10:00:00",
);
assert!(f.matches(&after)); assert!(f.matches(&after));
assert!(!f.matches(&before)); assert!(!f.matches(&before));
} }
@ -706,8 +811,22 @@ mod tests {
#[test] #[test]
fn match_date_lt() { fn match_date_lt() {
let f = Filter::parse("date<2026-03-20").unwrap(); let f = Filter::parse("date<2026-03-20").unwrap();
let before = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-15 10:00:00"); let before = make_row(
let after = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-25 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-15 10:00:00",
);
let after = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-25 10:00:00",
);
assert!(f.matches(&before)); assert!(f.matches(&before));
assert!(!f.matches(&after)); assert!(!f.matches(&after));
} }
@ -715,8 +834,22 @@ mod tests {
#[test] #[test]
fn match_date_colon_equality() { fn match_date_colon_equality() {
let f = Filter::parse("date:2026-03-20").unwrap(); let f = Filter::parse("date:2026-03-20").unwrap();
let exact = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-20 10:00:00"); let exact = make_row(
let other = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-21 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-20 10:00:00",
);
let other = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-21 10:00:00",
);
assert!(f.matches(&exact)); assert!(f.matches(&exact));
assert!(!f.matches(&other)); assert!(!f.matches(&other));
} }
@ -724,8 +857,22 @@ mod tests {
#[test] #[test]
fn match_date_range_and() { fn match_date_range_and() {
let f = Filter::parse("date>2026-03-15 AND date<2026-03-20").unwrap(); let f = Filter::parse("date>2026-03-15 AND date<2026-03-20").unwrap();
let inside = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-17 10:00:00"); let inside = make_row(
let outside = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-22 10:00:00"); "model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-17 10:00:00",
);
let outside = make_row(
"model",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-22 10:00:00",
);
assert!(f.matches(&inside)); assert!(f.matches(&inside));
assert!(!f.matches(&outside)); assert!(!f.matches(&outside));
} }
@ -735,9 +882,30 @@ mod tests {
#[test] #[test]
fn match_and_both_must_match() { fn match_and_both_must_match() {
let f = Filter::parse("model:haiku AND agents>0").unwrap(); let f = Filter::parse("model:haiku AND agents>0").unwrap();
let haiku_agents = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00"); let haiku_agents = make_row(
let haiku_no_agents = make_row("claude-haiku-4-5", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); "claude-haiku-4-5",
let sonnet_agents = make_row("claude-sonnet-4-6", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00"); "/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
let haiku_no_agents = make_row(
"claude-haiku-4-5",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let sonnet_agents = make_row(
"claude-sonnet-4-6",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
2,
"2026-03-30 10:00:00",
);
assert!(f.matches(&haiku_agents)); assert!(f.matches(&haiku_agents));
assert!(!f.matches(&haiku_no_agents)); assert!(!f.matches(&haiku_no_agents));
assert!(!f.matches(&sonnet_agents)); assert!(!f.matches(&sonnet_agents));
@ -746,9 +914,30 @@ mod tests {
#[test] #[test]
fn match_or_either_matches() { fn match_or_either_matches() {
let f = Filter::parse("model:haiku OR model:sonnet").unwrap(); let f = Filter::parse("model:haiku OR model:sonnet").unwrap();
let haiku = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let haiku = make_row(
let sonnet = make_row("claude-sonnet-4-6", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); "claude-haiku-4-5",
let opus = make_row("claude-opus-4-5", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); "/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let sonnet = make_row(
"claude-sonnet-4-6",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
let opus = make_row(
"claude-opus-4-5",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&haiku)); assert!(f.matches(&haiku));
assert!(f.matches(&sonnet)); assert!(f.matches(&sonnet));
assert!(!f.matches(&opus)); assert!(!f.matches(&opus));
@ -759,11 +948,32 @@ mod tests {
// "A OR B AND C" should parse as "A OR (B AND C)" — AND binds tighter. // "A OR B AND C" should parse as "A OR (B AND C)" — AND binds tighter.
let f = Filter::parse("model:opus OR model:haiku AND agents>0").unwrap(); let f = Filter::parse("model:opus OR model:haiku AND agents>0").unwrap();
// matches opus (left of OR) regardless of agents // matches opus (left of OR) regardless of agents
let opus_no_agents = make_row("claude-opus-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let opus_no_agents = make_row(
"claude-opus-4-5",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
// haiku without agents — does NOT match right side (haiku AND agents>0) // haiku without agents — does NOT match right side (haiku AND agents>0)
let haiku_no_agents = make_row("claude-haiku-4-5", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let haiku_no_agents = make_row(
"claude-haiku-4-5",
"/proj",
"bbbbbbbb-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
// haiku with agents — matches right side // haiku with agents — matches right side
let haiku_agents = make_row("claude-haiku-4-5", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 3, "2026-03-30 10:00:00"); let haiku_agents = make_row(
"claude-haiku-4-5",
"/proj",
"cccccccc-0000-0000-0000-000000000000",
5,
3,
"2026-03-30 10:00:00",
);
assert!(f.matches(&opus_no_agents)); assert!(f.matches(&opus_no_agents));
assert!(!f.matches(&haiku_no_agents)); assert!(!f.matches(&haiku_no_agents));
assert!(f.matches(&haiku_agents)); assert!(f.matches(&haiku_agents));
@ -775,7 +985,14 @@ mod tests {
fn tokens_filter_returns_false_for_session_list_item() { fn tokens_filter_returns_false_for_session_list_item() {
// SessionListItem does not track token count; tokens filter always false. // SessionListItem does not track token count; tokens filter always false.
let f = Filter::parse("tokens>100").unwrap(); let f = Filter::parse("tokens>100").unwrap();
let row = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00"); let row = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(!f.matches(&row)); assert!(!f.matches(&row));
} }
} }

@ -41,9 +41,7 @@ pub fn render_quit_dialog(f: &mut Frame, area: Rect) {
// Clear the background behind the dialog. // Clear the background behind the dialog.
f.render_widget(Clear, dialog_area); f.render_widget(Clear, dialog_area);
let block = Block::default() let block = Block::default().title(" Quit? ").borders(Borders::ALL);
.title(" Quit? ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(" q = yes Esc = no ") let paragraph = Paragraph::new(" q = yes Esc = no ")
.block(block) .block(block)

@ -12,13 +12,13 @@
use std::io::{self, Stdout}; use std::io::{self, Stdout};
use std::time::Duration; use std::time::Duration;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::crossterm::execute; use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{ use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
}; };
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use crate::error::Result; use crate::error::Result;
use crate::parser::discovery::{discover_agents_for_session, discover_sessions}; use crate::parser::discovery::{discover_agents_for_session, discover_sessions};

@ -26,7 +26,11 @@ fn truncate_project(path: &str, max_chars: usize) -> String {
path.to_string() path.to_string()
} else { } else {
// Keep the last (max_chars - 1) chars and prepend the ellipsis. // Keep the last (max_chars - 1) chars and prepend the ellipsis.
let keep: String = path.chars().rev().take(max_chars - 1).collect::<Vec<_>>() let keep: String = path
.chars()
.rev()
.take(max_chars - 1)
.collect::<Vec<_>>()
.into_iter() .into_iter()
.rev() .rev()
.collect(); .collect();
@ -110,9 +114,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
format!(" Sessions [filter: {}] ", state.filter_active) format!(" Sessions [filter: {}] ", state.filter_active)
}; };
let block = Block::default() let block = Block::default().title(table_title).borders(Borders::ALL);
.title(table_title)
.borders(Borders::ALL);
let highlight_style = Style::default().add_modifier(Modifier::REVERSED); let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
@ -242,8 +244,7 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
let visible = filtered_session_indices(state); let visible = filtered_session_indices(state);
if !visible.is_empty() { if !visible.is_empty() {
state.list_selected = state.list_selected = (state.list_selected + 1).min(visible.len() - 1);
(state.list_selected + 1).min(visible.len() - 1);
} }
true true
} }
@ -303,7 +304,11 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool {
let query = state.filter_input.trim().to_string(); let query = state.filter_input.trim().to_string();
// Record in history (skip duplicate consecutive entries). // Record in history (skip duplicate consecutive entries).
if !query.is_empty() { if !query.is_empty() {
let is_dup = state.filter_history.last().map(|l| l == &query).unwrap_or(false); let is_dup = state
.filter_history
.last()
.map(|l| l == &query)
.unwrap_or(false);
if !is_dup { if !is_dup {
state.filter_history.push(query.clone()); state.filter_history.push(query.clone());
AppState::append_history_to_disk(&query); AppState::append_history_to_disk(&query);
@ -428,8 +433,14 @@ mod tests {
#[test] #[test]
fn down_increments_selection() { fn down_increments_selection() {
let mut state = AppState::new(); let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000")); state.sessions.push(make_item(
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000")); "aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 0; state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Down), &mut state); let consumed = handle_session_list_event(press(KeyCode::Down), &mut state);
assert!(consumed); assert!(consumed);
@ -439,7 +450,10 @@ mod tests {
#[test] #[test]
fn down_clamps_at_end() { fn down_clamps_at_end() {
let mut state = AppState::new(); let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000")); state.sessions.push(make_item(
"aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.list_selected = 0; state.list_selected = 0;
handle_session_list_event(press(KeyCode::Down), &mut state); handle_session_list_event(press(KeyCode::Down), &mut state);
// Should remain at 0 (only one item). // Should remain at 0 (only one item).
@ -449,8 +463,14 @@ mod tests {
#[test] #[test]
fn up_decrements_selection() { fn up_decrements_selection() {
let mut state = AppState::new(); let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000")); state.sessions.push(make_item(
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000")); "aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 1; state.list_selected = 1;
let consumed = handle_session_list_event(press(KeyCode::Up), &mut state); let consumed = handle_session_list_event(press(KeyCode::Up), &mut state);
assert!(consumed); assert!(consumed);
@ -468,8 +488,14 @@ mod tests {
#[test] #[test]
fn j_increments_selection() { fn j_increments_selection() {
let mut state = AppState::new(); let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000")); state.sessions.push(make_item(
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000")); "aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 0; state.list_selected = 0;
handle_session_list_event(press(KeyCode::Char('j')), &mut state); handle_session_list_event(press(KeyCode::Char('j')), &mut state);
assert_eq!(state.list_selected, 1); assert_eq!(state.list_selected, 1);
@ -478,8 +504,14 @@ mod tests {
#[test] #[test]
fn k_decrements_selection() { fn k_decrements_selection() {
let mut state = AppState::new(); let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000")); state.sessions.push(make_item(
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000")); "aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 1; state.list_selected = 1;
handle_session_list_event(press(KeyCode::Char('k')), &mut state); handle_session_list_event(press(KeyCode::Char('k')), &mut state);
assert_eq!(state.list_selected, 0); assert_eq!(state.list_selected, 0);

@ -169,11 +169,13 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
} }
} }
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
let input_str = let input_str = serde_json::to_string(input).unwrap_or_default();
serde_json::to_string(input).unwrap_or_default();
let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE); let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE);
let ellipsis = let ellipsis = if input_str.len() > TOOL_INPUT_TRUNCATE {
if input_str.len() > TOOL_INPUT_TRUNCATE { "…" } else { "" }; "…"
} else {
""
};
let tool_style = if color_enabled { let tool_style = if color_enabled {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
} else { } else {
@ -187,20 +189,23 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
ContentBlock::ToolResult { ContentBlock::ToolResult {
content, is_error, .. content, is_error, ..
} => { } => {
let err_flag = let err_flag = if is_error.unwrap_or(false) {
if is_error.unwrap_or(false) { " (error)" } else { "" }; " (error)"
} else {
""
};
let preview = content let preview = content
.as_ref() .as_ref()
.and_then(|c| c.as_str().map(|s| s.to_string())) .and_then(|c| c.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| { .unwrap_or_else(|| {
content content.as_ref().map(|c| c.to_string()).unwrap_or_default()
.as_ref()
.map(|c| c.to_string())
.unwrap_or_default()
}); });
let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE); let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE);
let ellipsis = let ellipsis = if preview.len() > TOOL_RESULT_TRUNCATE {
if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" }; "…"
} else {
""
};
let style = if color_enabled { let style = if color_enabled {
if is_error.unwrap_or(false) { if is_error.unwrap_or(false) {
Style::default().fg(Color::Red) Style::default().fg(Color::Red)
@ -221,10 +226,7 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
} else { } else {
Style::default() Style::default()
}; };
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled("[image]".to_string(), img_style)));
"[image]".to_string(),
img_style,
)));
} }
ContentBlock::Unknown => { ContentBlock::Unknown => {
lines.push(Line::from("[unknown block]".to_string())); lines.push(Line::from("[unknown block]".to_string()));
@ -243,7 +245,7 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Style applied to matched substrings in search mode. /// Style applied to matched substrings in search mode.
const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Yellow).fg(Color::Black); const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Blue).fg(Color::White);
/// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`, /// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`]. /// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
@ -461,7 +463,10 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
let chat_paragraph = Paragraph::new(Text::from(chat_lines)) let chat_paragraph = Paragraph::new(Text::from(chat_lines))
.block(chat_block) .block(chat_block)
.scroll((state.transcript_scroll as u16, state.transcript_h_scroll as u16)); .scroll((
state.transcript_scroll as u16,
state.transcript_h_scroll as u16,
));
f.render_widget(chat_paragraph, body_chunks[0]); f.render_widget(chat_paragraph, body_chunks[0]);
@ -490,11 +495,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
} else { } else {
agent.agent_id.clone() agent.agent_id.clone()
}; };
let agent_type = agent let agent_type = agent.agent_type.as_deref().unwrap_or("agent").to_string();
.agent_type
.as_deref()
.unwrap_or("agent")
.to_string();
if i == state.subagent_selected && state.focus == Focus::SubagentsPanel { if i == state.subagent_selected && state.focus == Focus::SubagentsPanel {
Line::from(Span::styled( Line::from(Span::styled(
format!("> {short_id} {agent_type}"), format!("> {short_id} {agent_type}"),
@ -507,8 +508,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.collect() .collect()
}; };
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines)) let subagents_paragraph = Paragraph::new(Text::from(subagent_lines)).block(subagents_block);
.block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]); f.render_widget(subagents_paragraph, body_chunks[1]);
@ -571,8 +571,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
if !state.search_match_lines.is_empty() { if !state.search_match_lines.is_empty() {
state.search_current_match = state.search_current_match =
(state.search_current_match + 1) % state.search_match_lines.len(); (state.search_current_match + 1) % state.search_match_lines.len();
state.transcript_scroll = state.transcript_scroll = state.search_match_lines[state.search_current_match];
state.search_match_lines[state.search_current_match];
} }
true true
} }
@ -583,8 +582,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
.search_current_match .search_current_match
.checked_sub(1) .checked_sub(1)
.unwrap_or(state.search_match_lines.len() - 1); .unwrap_or(state.search_match_lines.len() - 1);
state.transcript_scroll = state.transcript_scroll = state.search_match_lines[state.search_current_match];
state.search_match_lines[state.search_current_match];
} }
true true
} }
@ -603,8 +601,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
Focus::SubagentsPanel => { Focus::SubagentsPanel => {
if !state.subagents.is_empty() { if !state.subagents.is_empty() {
let max = state.subagents.len() - 1; let max = state.subagents.len() - 1;
state.subagent_selected = state.subagent_selected = (state.subagent_selected + 1).min(max);
(state.subagent_selected + 1).min(max);
} }
true true
} }
@ -724,16 +721,14 @@ pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) {
}; };
// Find the session whose ID matches (supports full UUID or 8-char prefix). // Find the session whose ID matches (supports full UUID or 8-char prefix).
let session_ref = sessions.into_iter().find(|s| { let session_ref = sessions
s.session_id == session_id || s.session_id.starts_with(session_id) .into_iter()
}); .find(|s| s.session_id == session_id || s.session_id.starts_with(session_id));
let Some(sr) = session_ref else { return }; let Some(sr) = session_ref else { return };
// Discover sub-agents for the sidebar (best-effort). // Discover sub-agents for the sidebar (best-effort).
if let Ok(agents) = if let Ok(agents) = crate::parser::discovery::discover_agents_for_session(&sr.file_path) {
crate::parser::discovery::discover_agents_for_session(&sr.file_path)
{
state.subagents = agents; state.subagents = agents;
} }
@ -755,21 +750,16 @@ pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) {
/// ///
/// Walks all discovered agents to find the one whose `agent_id` matches, then /// Walks all discovered agents to find the one whose `agent_id` matches, then
/// reads and parses its JSONL file. /// reads and parses its JSONL file.
pub fn load_transcript_for_agent( pub fn load_transcript_for_agent(parent_session_id: &str, agent_id: &str, state: &mut AppState) {
parent_session_id: &str,
agent_id: &str,
state: &mut AppState,
) {
let sessions = match crate::parser::discovery::discover_sessions() { let sessions = match crate::parser::discovery::discover_sessions() {
Ok(s) => s, Ok(s) => s,
Err(_) => return, Err(_) => return,
}; };
// Find the parent session. // Find the parent session.
let session_ref = sessions.into_iter().find(|s| { let session_ref = sessions
s.session_id == parent_session_id .into_iter()
|| s.session_id.starts_with(parent_session_id) .find(|s| s.session_id == parent_session_id || s.session_id.starts_with(parent_session_id));
});
let Some(sr) = session_ref else { return }; let Some(sr) = session_ref else { return };
@ -905,11 +895,7 @@ mod tests {
let lines = build_header_lines("abc12345-0000-0000-0000-000000000000", &[]); let lines = build_header_lines("abc12345-0000-0000-0000-000000000000", &[]);
// Should have 1 line (no tool calls line when empty). // Should have 1 line (no tool calls line when empty).
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0] let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.contains("abc12345")); assert!(text.contains("abc12345"));
assert!(text.contains("Tokens: in=0 out=0")); assert!(text.contains("Tokens: in=0 out=0"));
} }
@ -930,11 +916,7 @@ mod tests {
}); });
let lines = build_header_lines("abc12345", &[entry]); let lines = build_header_lines("abc12345", &[entry]);
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
let tools_text: String = lines[1] let tools_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(tools_text.contains("Bash")); assert!(tools_text.contains("Bash"));
} }

Loading…
Cancel
Save