feat(claudbg-agi7): transcript search panel with highlight and n/N navigation

- Bottom search bar (3 rows, always visible), press 't' to focus
- Case-insensitive match highlighting (yellow bg) across all spans
- Panel title shows current/total match count (e.g. "2/7")
- 'n'/'N' navigate next/previous match, wrapping around
- Enter applies search and returns focus to chat log
- Esc clears search and returns focus to chat log
- Tab cycles ChatLog → SubagentsPanel → SearchInput → ChatLog
- Help modal updated with search keybindings

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

@ -0,0 +1,11 @@
---
# claudbg-agi7
title: TUI transcript search panel with highlight and n/N navigation
status: completed
type: feature
priority: normal
created_at: 2026-03-31T22:51:54Z
updated_at: 2026-03-31T23:02:42Z
---
Add a search panel to transcript screens (bottom, like filter panel). Press 't' to focus search input. Highlights all case-insensitive matches. Press 'n'/'N' to jump to next/previous match. Panel shows match count. Enter returns focus to transcript.

@ -16,7 +16,7 @@ use crate::tui::state::AppState;
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 17;
const DIALOG_HEIGHT: u16 = 20;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -43,6 +43,12 @@ const HELP_TEXT: &str = "\
Tab cycle panes\n\
Enter open/select\n\
Esc go back\n\
\n\
Search (transcript)\n\
t open search\n\
n / N next/prev match\n\
Enter apply & close\n\
Esc clear & close\n\
\n\
Global\n\
q/Q quit\n\

@ -228,11 +228,11 @@ pub fn run_tui() -> Result<()> {
// Record the visible chat-log height before rendering so that
// Space / PageDown / PageUp can scroll by exactly one page.
// Layout: 4-row stats header + 2 border rows = 6 fixed rows.
// Layout: 4-row stats header + 3-row search bar + 2 border rows = 9 fixed rows.
state.transcript_page_height = guard
.terminal
.size()
.map(|s| s.height.saturating_sub(6))
.map(|s| s.height.saturating_sub(9))
.unwrap_or(0);
guard.terminal.draw(|f| render(f, &state))?;

@ -238,6 +238,102 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
lines
}
// ---------------------------------------------------------------------------
// Search helpers
// ---------------------------------------------------------------------------
/// Style applied to matched substrings in search mode.
const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Yellow).fg(Color::Black);
/// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
///
/// Only operates on ASCII-safe byte positions — if `text` and its lowercase
/// counterpart have different byte lengths (multi-byte Unicode edge case) the
/// original span is returned unchanged.
fn split_span_with_search(span: Span<'static>, query_lower: &str) -> Vec<Span<'static>> {
let original_style = span.style;
let text = span.content.into_owned();
let text_lower = text.to_lowercase();
// Guard: only byte-level slice when lengths match (safe for ASCII / common Unicode).
if text.len() != text_lower.len() || !text_lower.contains(query_lower) {
return vec![Span::styled(text, original_style)];
}
let mut result: Vec<Span<'static>> = Vec::new();
let mut pos = 0usize;
while pos < text.len() {
match text_lower[pos..].find(query_lower) {
None => {
result.push(Span::styled(text[pos..].to_string(), original_style));
break;
}
Some(rel) => {
let abs = pos + rel;
if abs > pos {
result.push(Span::styled(text[pos..abs].to_string(), original_style));
}
result.push(Span::styled(
text[abs..abs + query_lower.len()].to_string(),
SEARCH_HIGHLIGHT,
));
pos = abs + query_lower.len();
}
}
}
if result.is_empty() {
result.push(Span::styled(text, original_style));
}
result
}
/// Post-process rendered chat lines to highlight all case-insensitive matches
/// of `query`. Returns lines unchanged when `query` is empty.
fn apply_search_highlights(lines: Vec<Line<'static>>, query: &str) -> Vec<Line<'static>> {
if query.is_empty() {
return lines;
}
let query_lower = query.to_lowercase();
lines
.into_iter()
.map(|line| {
// Quick check: does the plain text of this line contain the query?
let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if !plain.to_lowercase().contains(&*query_lower) {
return line;
}
// Rebuild spans, splitting around matches.
let new_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.flat_map(|sp| split_span_with_search(sp, &query_lower))
.collect();
Line::from(new_spans)
})
.collect()
}
/// Return the indices of chat-log lines that contain `query` (case-insensitive).
///
/// Calls [`build_chat_lines`] to get the rendered lines, then filters by text content.
/// Returns an empty vec when `query` is empty.
pub fn find_match_lines(entries: &[RawEntry], query: &str, color_enabled: bool) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
build_chat_lines(entries, color_enabled)
.iter()
.enumerate()
.filter(|(_, line)| {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
text.to_lowercase().contains(&*query_lower)
})
.map(|(i, _)| i)
.collect()
}
/// Truncate a string to at most `max_chars` chars (Unicode-safe).
fn truncate_str(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
@ -252,11 +348,60 @@ fn truncate_str(s: &str, max_chars: usize) -> String {
// Rendering
// ---------------------------------------------------------------------------
/// Draw the search bar at the bottom of the transcript area.
fn render_search_bar(f: &mut Frame, area: Rect, state: &AppState) {
let focused = state.focus == Focus::SearchInput;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let match_info = if !state.search_active.is_empty() {
let total = state.search_match_lines.len();
if total == 0 {
" no matches".to_string()
} else {
let cur = state.search_current_match + 1;
format!(" {cur}/{total}")
}
} else {
String::new()
};
let block = Block::default()
.title(format!(" Search{match_info} "))
.borders(Borders::ALL)
.border_style(border_style);
let label = Span::styled("Search: ", Style::default().add_modifier(Modifier::BOLD));
let input_line = if focused {
let mut spans = vec![label, Span::raw(state.search_input.clone())];
spans.push(Span::styled("█", Style::default().fg(Color::Yellow)));
Line::from(spans)
} else if state.search_input.is_empty() && state.search_active.is_empty() {
Line::from(vec![
label,
Span::styled(
"Press 't' to search — type and press Enter to highlight matches",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![label, Span::raw(state.search_input.clone())])
};
let paragraph = Paragraph::new(input_line).block(block);
f.render_widget(paragraph, area);
}
/// Draw the transcript screen onto `area` of the given frame.
///
/// Layout:
/// - Top 4 lines: stats header (inside a bordered block).
/// - Remaining lines: scrollable chat log.
/// - Middle: scrollable chat log (left) + sub-agents panel (right).
/// - Bottom 3 lines: search bar.
pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
// Determine the session_id for the header.
let session_id = match &state.screen {
@ -265,12 +410,11 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
_ => String::new(),
};
// Split area: header (fixed) | chat log (min).
// Header block: 2 content lines + 2 border lines = 4 rows total.
let header_height: u16 = 4;
// Split area: header (fixed) | body (min) | search bar (fixed).
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Min(1),
Constraint::Length(4), // stats header
Constraint::Min(1), // chat log + sub-agents
Constraint::Length(3), // search bar
])
.split(area);
@ -292,7 +436,10 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.split(chunks[1]);
// ── Chat log ─────────────────────────────────────────────────────────────
let chat_lines = build_chat_lines(&state.transcript_entries, state.color_enabled);
let mut chat_lines = build_chat_lines(&state.transcript_entries, state.color_enabled);
if !state.search_active.is_empty() {
chat_lines = apply_search_highlights(chat_lines, &state.search_active);
}
let chat_border_style = if state.focus == Focus::ChatLog {
Style::default().fg(Color::Yellow)
@ -300,8 +447,15 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
Style::default()
};
let search_hint = if !state.search_active.is_empty() {
" · n/N match"
} else {
""
};
let chat_block = Block::default()
.title(" Transcript [↑/↓ scroll · Spc/PgDn page · ←/→ h-scroll · Tab · Esc · ?] ")
.title(format!(
" Transcript [↑/↓ scroll · Spc/PgDn page · t search{search_hint} · Tab · Esc · ?] "
))
.borders(Borders::ALL)
.border_style(chat_border_style);
@ -357,6 +511,9 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]);
// ── Search bar ───────────────────────────────────────────────────────────
render_search_bar(f, chunks[2], state);
}
// ---------------------------------------------------------------------------
@ -374,12 +531,18 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
return false;
}
// When the search bar is focused, route all input there.
if state.focus == Focus::SearchInput {
return handle_search_input_event(key.code, state);
}
match key.code {
// Toggle focus between ChatLog and SubagentsPanel.
// Toggle focus: ChatLog → SubagentsPanel → SearchInput → ChatLog.
KeyCode::Tab => {
state.focus = match state.focus {
Focus::ChatLog => Focus::SubagentsPanel,
Focus::SubagentsPanel | Focus::FilterInput => Focus::ChatLog,
Focus::SubagentsPanel => Focus::SearchInput,
Focus::SearchInput | Focus::FilterInput => Focus::ChatLog,
};
true
}
@ -398,13 +561,40 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state.show_help = true;
true
}
// Jump directly to the search input.
KeyCode::Char('t') => {
state.focus = Focus::SearchInput;
true
}
// Next match.
KeyCode::Char('n') => {
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];
}
true
}
// Previous match (Shift+N).
KeyCode::Char('N') => {
if !state.search_match_lines.is_empty() {
state.search_current_match = state
.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];
}
true
}
// Directional keys are routed based on the focused panel.
KeyCode::Up | KeyCode::Char('k') => match state.focus {
Focus::SubagentsPanel => {
state.subagent_selected = state.subagent_selected.saturating_sub(1);
true
}
Focus::ChatLog | Focus::FilterInput => {
Focus::ChatLog | Focus::FilterInput | Focus::SearchInput => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true
}
@ -418,7 +608,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
true
}
Focus::ChatLog | Focus::FilterInput => {
Focus::ChatLog | Focus::FilterInput | Focus::SearchInput => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true
}
@ -469,6 +659,53 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
}
/// Handle a key event while the search input bar is focused.
///
/// Returns `true` when the event is consumed.
fn handle_search_input_event(code: KeyCode, state: &mut AppState) -> bool {
match code {
// Character input: append to search_input.
KeyCode::Char(c) => {
state.search_input.push(c);
true
}
// Backspace: remove last char.
KeyCode::Backspace => {
state.search_input.pop();
true
}
// Enter: apply the search, compute match lines, return focus to ChatLog.
KeyCode::Enter => {
let query = state.search_input.trim().to_string();
state.search_active = query.clone();
state.search_match_lines =
find_match_lines(&state.transcript_entries, &query, state.color_enabled);
state.search_current_match = 0;
// Scroll to first match if any.
if let Some(&first) = state.search_match_lines.first() {
state.transcript_scroll = first;
}
state.focus = Focus::ChatLog;
true
}
// Escape: clear search and return focus to ChatLog.
KeyCode::Esc => {
state.search_input.clear();
state.search_active.clear();
state.search_match_lines.clear();
state.search_current_match = 0;
state.focus = Focus::ChatLog;
true
}
// Tab: cycle focus back to ChatLog.
KeyCode::Tab => {
state.focus = Focus::ChatLog;
true
}
_ => true, // consume all other keys while search is focused
}
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
@ -923,12 +1160,14 @@ mod tests {
}
#[test]
fn tab_toggles_focus() {
fn tab_cycles_focus() {
let mut state = transcript_state();
assert_eq!(state.focus, Focus::ChatLog);
handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::SubagentsPanel);
handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::SearchInput);
handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::ChatLog);
}
@ -1019,6 +1258,155 @@ mod tests {
assert_eq!(state.transcript_scroll, 0);
}
// ── Search input focus ───────────────────────────────────────────────────
#[test]
fn t_key_focuses_search_input() {
let mut state = transcript_state();
let consumed = handle_transcript_event(press(KeyCode::Char('t')), &mut state);
assert!(consumed);
assert_eq!(state.focus, Focus::SearchInput);
}
#[test]
fn search_char_input_appends() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
handle_transcript_event(press(KeyCode::Char('h')), &mut state);
handle_transcript_event(press(KeyCode::Char('i')), &mut state);
assert_eq!(state.search_input, "hi");
}
#[test]
fn search_backspace_removes_char() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "hi".to_string();
handle_transcript_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.search_input, "h");
}
#[test]
fn search_enter_applies_query_and_returns_focus() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "user".to_string();
handle_transcript_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.search_active, "user");
assert_eq!(state.focus, Focus::ChatLog);
}
#[test]
fn search_esc_clears_and_returns_focus() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "hi".to_string();
state.search_active = "hi".to_string();
state.search_match_lines = vec![1, 3];
handle_transcript_event(press(KeyCode::Esc), &mut state);
assert_eq!(state.search_input, "");
assert_eq!(state.search_active, "");
assert!(state.search_match_lines.is_empty());
assert_eq!(state.focus, Focus::ChatLog);
}
// ── n/N match navigation ─────────────────────────────────────────────────
#[test]
fn n_advances_to_next_match() {
let mut state = transcript_state();
state.search_match_lines = vec![2, 5, 9];
state.search_current_match = 0;
handle_transcript_event(press(KeyCode::Char('n')), &mut state);
assert_eq!(state.search_current_match, 1);
assert_eq!(state.transcript_scroll, 5);
}
#[test]
fn n_wraps_to_first_match() {
let mut state = transcript_state();
state.search_match_lines = vec![2, 5, 9];
state.search_current_match = 2; // last
handle_transcript_event(press(KeyCode::Char('n')), &mut state);
assert_eq!(state.search_current_match, 0);
assert_eq!(state.transcript_scroll, 2);
}
#[test]
fn big_n_goes_to_previous_match() {
let mut state = transcript_state();
state.search_match_lines = vec![2, 5, 9];
state.search_current_match = 2;
handle_transcript_event(press(KeyCode::Char('N')), &mut state);
assert_eq!(state.search_current_match, 1);
assert_eq!(state.transcript_scroll, 5);
}
#[test]
fn big_n_wraps_to_last_match() {
let mut state = transcript_state();
state.search_match_lines = vec![2, 5, 9];
state.search_current_match = 0; // first
handle_transcript_event(press(KeyCode::Char('N')), &mut state);
assert_eq!(state.search_current_match, 2);
assert_eq!(state.transcript_scroll, 9);
}
#[test]
fn n_with_no_matches_does_nothing() {
let mut state = transcript_state();
state.search_match_lines = vec![];
state.transcript_scroll = 3;
handle_transcript_event(press(KeyCode::Char('n')), &mut state);
assert_eq!(state.transcript_scroll, 3); // unchanged
}
// ── find_match_lines + highlight helpers ─────────────────────────────────
#[test]
fn find_match_lines_empty_query_returns_empty() {
let result = find_match_lines(&[], "", false);
assert!(result.is_empty());
}
#[test]
fn find_match_lines_finds_case_insensitive() {
let entries = vec![
user_text_entry("Hello Claude"),
user_text_entry("world"),
user_text_entry("hello again"),
];
let lines = find_match_lines(&entries, "hello", false);
// Lines 0 and 2 (from entries 0 and 2) should match.
assert_eq!(lines.len(), 2);
}
#[test]
fn apply_search_highlights_empty_query_unchanged() {
let original = vec![Line::from("some text")];
let result = apply_search_highlights(original.clone(), "");
assert_eq!(result.len(), original.len());
}
#[test]
fn split_span_with_search_no_match_unchanged() {
let span = Span::raw("hello world");
let result = split_span_with_search(span, "xyz");
assert_eq!(result.len(), 1);
assert_eq!(result[0].content, "hello world");
}
#[test]
fn split_span_with_search_highlights_match() {
let span = Span::raw("hello world");
let result = split_span_with_search(span, "world");
// Expect: "hello " + highlighted "world"
assert_eq!(result.len(), 2);
assert_eq!(result[0].content, "hello ");
assert_eq!(result[1].content, "world");
assert_eq!(result[1].style, SEARCH_HIGHLIGHT);
}
#[test]
fn page_scroll_uses_one_when_height_is_zero() {
let mut state = transcript_state();

@ -80,6 +80,8 @@ pub enum Focus {
SubagentsPanel,
/// The filter input bar at the bottom of the session-list screen.
FilterInput,
/// The search input bar at the bottom of the transcript screen.
SearchInput,
}
// ---------------------------------------------------------------------------
@ -121,6 +123,18 @@ pub struct AppState {
/// Index of the highlighted row in the sub-agents panel.
pub subagent_selected: usize,
// ── Search ──────────────────────────────────────────────────────────────
/// Text currently being typed in the transcript search box.
pub search_input: String,
/// The last applied search query (highlights all case-insensitive matches).
/// Empty string means no search is active.
pub search_active: String,
/// Line indices (in the rendered chat lines) where `search_active` matches.
/// Populated on Enter; used for n/N navigation.
pub search_match_lines: Vec<usize>,
/// Index into `search_match_lines` for the match currently centred by n/N.
pub search_current_match: usize,
// ── Focus ───────────────────────────────────────────────────────────────
/// Which panel currently holds keyboard focus.
pub focus: Focus,
@ -213,6 +227,10 @@ impl AppState {
transcript_scroll: 0,
transcript_h_scroll: 0,
transcript_page_height: 0,
search_input: String::new(),
search_active: String::new(),
search_match_lines: Vec::new(),
search_current_match: 0,
subagents: Vec::new(),
subagent_selected: 0,
focus: Focus::default(),
@ -240,6 +258,10 @@ impl AppState {
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.search_input.clear();
self.search_active.clear();
self.search_match_lines.clear();
self.search_current_match = 0;
self.subagents.clear();
self.subagent_selected = 0;
self.focus = Focus::ChatLog;
@ -261,6 +283,10 @@ impl AppState {
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.search_input.clear();
self.search_active.clear();
self.search_match_lines.clear();
self.search_current_match = 0;
self.focus = Focus::ChatLog;
}
@ -274,6 +300,10 @@ impl AppState {
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.search_input.clear();
self.search_active.clear();
self.search_match_lines.clear();
self.search_current_match = 0;
self.subagents.clear();
self.subagent_selected = 0;
self.focus = Focus::ChatLog;

Loading…
Cancel
Save