From 3ba8cf0e077014f4c6b2622bb532cea10bac71b0 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 31 Mar 2026 16:02:50 -0700 Subject: [PATCH] feat(claudbg-agi7): transcript search panel with highlight and n/N navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...ript-search-panel-with-highlight-and-nn.md | 11 + src/tui/modals/help_modal.rs | 8 +- src/tui/run.rs | 4 +- src/tui/screens/transcript.rs | 414 +++++++++++++++++- src/tui/state.rs | 30 ++ 5 files changed, 451 insertions(+), 16 deletions(-) create mode 100644 .beans/claudbg-agi7--tui-transcript-search-panel-with-highlight-and-nn.md diff --git a/.beans/claudbg-agi7--tui-transcript-search-panel-with-highlight-and-nn.md b/.beans/claudbg-agi7--tui-transcript-search-panel-with-highlight-and-nn.md new file mode 100644 index 0000000..03880c2 --- /dev/null +++ b/.beans/claudbg-agi7--tui-transcript-search-panel-with-highlight-and-nn.md @@ -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. diff --git a/src/tui/modals/help_modal.rs b/src/tui/modals/help_modal.rs index 631b6ad..b0a0143 100644 --- a/src/tui/modals/help_modal.rs +++ b/src/tui/modals/help_modal.rs @@ -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\ diff --git a/src/tui/run.rs b/src/tui/run.rs index ed02c7e..1be5ac5 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -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))?; diff --git a/src/tui/screens/transcript.rs b/src/tui/screens/transcript.rs index 2d763b1..2bc3fef 100644 --- a/src/tui/screens/transcript.rs +++ b/src/tui/screens/transcript.rs @@ -238,6 +238,102 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec, query_lower: &str) -> Vec> { + 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> = 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>, query: &str) -> Vec> { + 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> = 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 { + 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(); diff --git a/src/tui/state.rs b/src/tui/state.rs index f75dc95..54fc087 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -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, + /// 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;