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
title: Add --[no-]color global flag and NO_COLOR env var support
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:32:57Z
updated_at: 2026-03-31T00:32:57Z
updated_at: 2026-04-01T05:44:59Z
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).
## 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
title: Limit list output to 10 by default with --limit flag
status: todo
status: completed
type: feature
priority: normal
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.
## 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
title: 'sessions: no sub-command defaults to sessions list'
status: todo
status: completed
type: feature
priority: normal
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.
## 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
title: 'TUI: search highlight text unreadable in light mode'
status: todo
status: completed
type: bug
priority: normal
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.
## 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 boundary = input_preview.floor_char_boundary(cap);
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);
println!("{label} {input_short}{ellipsis}");
}
@ -565,7 +569,13 @@ 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(), vec![], &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:?}"

@ -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 boundary = input_preview.floor_char_boundary(cap);
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);
println!("{label} {input_short}{ellipsis}");
}
@ -328,12 +332,10 @@ pub async fn list(
let rows = limit.apply(rows);
let output = match opts.output {
crate::cli::OutputFormat::Table => {
crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows,
)?
}
crate::cli::OutputFormat::Table => crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows,
)?,
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
@ -351,7 +353,14 @@ pub async fn list(
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Xml => crate::output::render_xml_rows(
&["session_id", "date", "project", "model", "messages", "subagents"],
&[
"session_id",
"date",
"project",
"model",
"messages",
"subagents",
],
&rows,
)?,
};

@ -84,7 +84,9 @@ impl SessionRow for crate::tui::state::SessionListItem {
}
fn date(&self) -> Option<NaiveDate> {
// `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.
if matches!(key, Key::Model | Key::Project | Key::Id)
&& !matches!(op, Op::Colon)
{
if matches!(key, Key::Model | Key::Project | Key::Id) && !matches!(op, Op::Colon) {
return Err(AppError::Parse(format!(
"key '{key_str}' only supports ':' operator"
)));
@ -622,29 +622,64 @@ mod tests {
#[test]
fn match_model_substring() {
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));
}
#[test]
fn match_model_substring_case_insensitive() {
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));
}
#[test]
fn no_match_model_substring() {
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));
}
#[test]
fn match_model_wildcard() {
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_empty_model = make_row("", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
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_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_empty_model));
}
@ -652,14 +687,28 @@ mod tests {
#[test]
fn match_project_substring() {
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));
}
#[test]
fn match_id_substring() {
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));
}
@ -668,8 +717,22 @@ mod tests {
#[test]
fn match_agents_gt() {
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 no_agents = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
let with_agents = make_row(
"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(&no_agents));
}
@ -677,8 +740,22 @@ mod tests {
#[test]
fn match_messages_lt() {
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 many = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 20, 0, "2026-03-30 10:00:00");
let few = make_row(
"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(&many));
}
@ -686,8 +763,22 @@ mod tests {
#[test]
fn match_messages_colon_equality() {
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 six = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 6, 0, "2026-03-30 10:00:00");
let five = make_row(
"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(&six));
}
@ -697,8 +788,22 @@ mod tests {
#[test]
fn match_date_gt() {
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 before = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-10 10:00:00");
let after = make_row(
"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(&before));
}
@ -706,8 +811,22 @@ mod tests {
#[test]
fn match_date_lt() {
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 after = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-25 10:00:00");
let before = make_row(
"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(&after));
}
@ -715,8 +834,22 @@ mod tests {
#[test]
fn match_date_colon_equality() {
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 other = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-21 10:00:00");
let exact = make_row(
"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(&other));
}
@ -724,8 +857,22 @@ mod tests {
#[test]
fn match_date_range_and() {
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 outside = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-22 10:00:00");
let inside = make_row(
"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(&outside));
}
@ -735,9 +882,30 @@ mod tests {
#[test]
fn match_and_both_must_match() {
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_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");
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_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_no_agents));
assert!(!f.matches(&sonnet_agents));
@ -746,9 +914,30 @@ mod tests {
#[test]
fn match_or_either_matches() {
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 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");
let haiku = make_row(
"claude-haiku-4-5",
"/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(&sonnet));
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.
let f = Filter::parse("model:opus OR model:haiku AND agents>0").unwrap();
// 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)
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
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(&haiku_no_agents));
assert!(f.matches(&haiku_agents));
@ -775,7 +985,14 @@ mod tests {
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();
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));
}
}

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

@ -12,13 +12,13 @@
use std::io::{self, Stdout};
use std::time::Duration;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use crate::error::Result;
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()
} else {
// 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()
.rev()
.collect();
@ -58,7 +62,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
let widths = [
Constraint::Length(8),
Constraint::Length(20),
Constraint::Min(10), // project — gets remaining space
Constraint::Min(10), // project — gets remaining space
Constraint::Length(20),
Constraint::Length(6),
Constraint::Length(7),
@ -72,7 +76,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Use 30 chars as a safe default; the Min constraint will expand it.
let project_max: usize = table_area
.width
.saturating_sub(68) // subtract fixed columns + separators + borders
.saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips
@ -110,9 +114,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
format!(" Sessions [filter: {}] ", state.filter_active)
};
let block = Block::default()
.title(table_title)
.borders(Borders::ALL);
let block = Block::default().title(table_title).borders(Borders::ALL);
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') => {
let visible = filtered_session_indices(state);
if !visible.is_empty() {
state.list_selected =
(state.list_selected + 1).min(visible.len() - 1);
state.list_selected = (state.list_selected + 1).min(visible.len() - 1);
}
true
}
@ -303,7 +304,11 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool {
let query = state.filter_input.trim().to_string();
// Record in history (skip duplicate consecutive entries).
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 {
state.filter_history.push(query.clone());
AppState::append_history_to_disk(&query);
@ -428,8 +433,14 @@ mod tests {
#[test]
fn down_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.sessions.push(make_item(
"aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Down), &mut state);
assert!(consumed);
@ -439,7 +450,10 @@ mod tests {
#[test]
fn down_clamps_at_end() {
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;
handle_session_list_event(press(KeyCode::Down), &mut state);
// Should remain at 0 (only one item).
@ -449,8 +463,14 @@ mod tests {
#[test]
fn up_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.sessions.push(make_item(
"aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 1;
let consumed = handle_session_list_event(press(KeyCode::Up), &mut state);
assert!(consumed);
@ -468,8 +488,14 @@ mod tests {
#[test]
fn j_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.sessions.push(make_item(
"aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Char('j')), &mut state);
assert_eq!(state.list_selected, 1);
@ -478,8 +504,14 @@ mod tests {
#[test]
fn k_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.sessions.push(make_item(
"aaaaaaaa",
"aaaaaaaa-0000-0000-0000-000000000000",
));
state.sessions.push(make_item(
"bbbbbbbb",
"bbbbbbbb-0000-0000-0000-000000000000",
));
state.list_selected = 1;
handle_session_list_event(press(KeyCode::Char('k')), &mut state);
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, .. } => {
let input_str =
serde_json::to_string(input).unwrap_or_default();
let input_str = serde_json::to_string(input).unwrap_or_default();
let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE);
let ellipsis =
if input_str.len() > TOOL_INPUT_TRUNCATE { "…" } else { "" };
let ellipsis = if input_str.len() > TOOL_INPUT_TRUNCATE {
"…"
} else {
""
};
let tool_style = if color_enabled {
Style::default().fg(Color::Cyan)
} else {
@ -187,20 +189,23 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
ContentBlock::ToolResult {
content, is_error, ..
} => {
let err_flag =
if is_error.unwrap_or(false) { " (error)" } else { "" };
let err_flag = if is_error.unwrap_or(false) {
" (error)"
} else {
""
};
let preview = content
.as_ref()
.and_then(|c| c.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| {
content
.as_ref()
.map(|c| c.to_string())
.unwrap_or_default()
content.as_ref().map(|c| c.to_string()).unwrap_or_default()
});
let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE);
let ellipsis =
if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" };
let ellipsis = if preview.len() > TOOL_RESULT_TRUNCATE {
"…"
} else {
""
};
let style = if color_enabled {
if is_error.unwrap_or(false) {
Style::default().fg(Color::Red)
@ -221,10 +226,7 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
"[image]".to_string(),
img_style,
)));
lines.push(Line::from(Span::styled("[image]".to_string(), img_style)));
}
ContentBlock::Unknown => {
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.
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`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
@ -412,9 +414,9 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
// Split area: header (fixed) | body (min) | search bar (fixed).
let chunks = Layout::vertical([
Constraint::Length(4), // stats header
Constraint::Min(1), // chat log + sub-agents
Constraint::Length(3), // search bar
Constraint::Length(4), // stats header
Constraint::Min(1), // chat log + sub-agents
Constraint::Length(3), // search bar
])
.split(area);
@ -461,7 +463,10 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
let chat_paragraph = Paragraph::new(Text::from(chat_lines))
.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]);
@ -490,11 +495,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
} else {
agent.agent_id.clone()
};
let agent_type = agent
.agent_type
.as_deref()
.unwrap_or("agent")
.to_string();
let agent_type = agent.agent_type.as_deref().unwrap_or("agent").to_string();
if i == state.subagent_selected && state.focus == Focus::SubagentsPanel {
Line::from(Span::styled(
format!("> {short_id} {agent_type}"),
@ -507,8 +508,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.collect()
};
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines))
.block(subagents_block);
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines)).block(subagents_block);
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() {
state.search_current_match =
(state.search_current_match + 1) % state.search_match_lines.len();
state.transcript_scroll =
state.search_match_lines[state.search_current_match];
state.transcript_scroll = state.search_match_lines[state.search_current_match];
}
true
}
@ -583,8 +582,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
.search_current_match
.checked_sub(1)
.unwrap_or(state.search_match_lines.len() - 1);
state.transcript_scroll =
state.search_match_lines[state.search_current_match];
state.transcript_scroll = state.search_match_lines[state.search_current_match];
}
true
}
@ -603,8 +601,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
Focus::SubagentsPanel => {
if !state.subagents.is_empty() {
let max = state.subagents.len() - 1;
state.subagent_selected =
(state.subagent_selected + 1).min(max);
state.subagent_selected = (state.subagent_selected + 1).min(max);
}
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).
let session_ref = sessions.into_iter().find(|s| {
s.session_id == session_id || s.session_id.starts_with(session_id)
});
let session_ref = sessions
.into_iter()
.find(|s| s.session_id == session_id || s.session_id.starts_with(session_id));
let Some(sr) = session_ref else { return };
// Discover sub-agents for the sidebar (best-effort).
if let Ok(agents) =
crate::parser::discovery::discover_agents_for_session(&sr.file_path)
{
if let Ok(agents) = crate::parser::discovery::discover_agents_for_session(&sr.file_path) {
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
/// reads and parses its JSONL file.
pub fn load_transcript_for_agent(
parent_session_id: &str,
agent_id: &str,
state: &mut AppState,
) {
pub fn load_transcript_for_agent(parent_session_id: &str, agent_id: &str, state: &mut AppState) {
let sessions = match crate::parser::discovery::discover_sessions() {
Ok(s) => s,
Err(_) => return,
};
// Find the parent session.
let session_ref = sessions.into_iter().find(|s| {
s.session_id == parent_session_id
|| s.session_id.starts_with(parent_session_id)
});
let session_ref = sessions
.into_iter()
.find(|s| s.session_id == parent_session_id || s.session_id.starts_with(parent_session_id));
let Some(sr) = session_ref else { return };
@ -905,11 +895,7 @@ mod tests {
let lines = build_header_lines("abc12345-0000-0000-0000-000000000000", &[]);
// Should have 1 line (no tool calls line when empty).
assert_eq!(lines.len(), 1);
let text: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("abc12345"));
assert!(text.contains("Tokens: in=0 out=0"));
}
@ -930,11 +916,7 @@ mod tests {
});
let lines = build_header_lines("abc12345", &[entry]);
assert_eq!(lines.len(), 2);
let tools_text: String = lines[1]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let tools_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(tools_text.contains("Bash"));
}

Loading…
Cancel
Save