Compare commits

..

No commits in common. '3ba8cf0e077014f4c6b2622bb532cea10bac71b0' and '9be62abb78ac0148e736e0f1324196682e9dbc03' have entirely different histories.

@ -1,11 +0,0 @@
---
# 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.

@ -1,13 +0,0 @@
---
# claudbg-nypt
title: Space/PageDown scrolls down, Shift+Space/PageUp scrolls up in TUI
status: completed
type: feature
priority: normal
created_at: 2026-03-31T22:44:51Z
updated_at: 2026-03-31T22:51:43Z
---
Pressing Space should scroll down one page in TUI (like less/more). Shift+Space scrolls up one page. PageDown/PageUp should also scroll by page.
## Summary\n\n- Added to , set each frame via \n- / scroll down one page; / scroll up one page\n- Updated block title hint and help modal (⇧Spc/PgUp entry)\n- 6 new tests

@ -16,7 +16,7 @@ use crate::tui::state::AppState;
/// Dialog dimensions. /// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32; const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 20; const DIALOG_HEIGHT: u16 = 15;
/// Compute a centered [`Rect`] of the given size within `area`. /// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -37,18 +37,10 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
const HELP_TEXT: &str = "\ const HELP_TEXT: &str = "\
Navigation\n\ Navigation\n\
\u{2191}/\u{2193} k/j scroll up/dn\n\ \u{2191}/\u{2193} k/j scroll up/dn\n\
Spc/PgDn page down\n\
\u{21e7}Spc/PgUp page up\n\
\u{2190}/\u{2192} h/l scroll lr\n\ \u{2190}/\u{2192} h/l scroll lr\n\
Tab cycle panes\n\ Tab cycle panes\n\
Enter open/select\n\ Enter open/select\n\
Esc go back\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\ \n\
Global\n\ Global\n\
q/Q quit\n\ q/Q quit\n\

@ -226,14 +226,6 @@ pub fn run_tui() -> Result<()> {
// Load transcript data lazily when entering a transcript screen. // Load transcript data lazily when entering a transcript screen.
maybe_load_transcript(&mut state); maybe_load_transcript(&mut state);
// Record the visible chat-log height before rendering so that
// Space / PageDown / PageUp can scroll by exactly one page.
// 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(9))
.unwrap_or(0);
guard.terminal.draw(|f| render(f, &state))?; guard.terminal.draw(|f| render(f, &state))?;
if event::poll(Duration::from_millis(50))? { if event::poll(Duration::from_millis(50))? {

@ -5,7 +5,7 @@
//! a scrollable chat log below. //! a scrollable chat log below.
use ratatui::Frame; use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text}; use ratatui::text::{Line, Span, Text};
@ -238,102 +238,6 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
lines 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). /// Truncate a string to at most `max_chars` chars (Unicode-safe).
fn truncate_str(s: &str, max_chars: usize) -> String { fn truncate_str(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count(); let char_count = s.chars().count();
@ -348,60 +252,11 @@ fn truncate_str(s: &str, max_chars: usize) -> String {
// Rendering // 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. /// Draw the transcript screen onto `area` of the given frame.
/// ///
/// Layout: /// Layout:
/// - Top 4 lines: stats header (inside a bordered block). /// - Top 4 lines: stats header (inside a bordered block).
/// - Middle: scrollable chat log (left) + sub-agents panel (right). /// - Remaining lines: scrollable chat log.
/// - Bottom 3 lines: search bar.
pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) { pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
// Determine the session_id for the header. // Determine the session_id for the header.
let session_id = match &state.screen { let session_id = match &state.screen {
@ -410,11 +265,12 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
_ => String::new(), _ => String::new(),
}; };
// Split area: header (fixed) | body (min) | search bar (fixed). // Split area: header (fixed) | chat log (min).
// Header block: 2 content lines + 2 border lines = 4 rows total.
let header_height: u16 = 4;
let chunks = Layout::vertical([ let chunks = Layout::vertical([
Constraint::Length(4), // stats header Constraint::Length(header_height),
Constraint::Min(1), // chat log + sub-agents Constraint::Min(1),
Constraint::Length(3), // search bar
]) ])
.split(area); .split(area);
@ -436,10 +292,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.split(chunks[1]); .split(chunks[1]);
// ── Chat log ───────────────────────────────────────────────────────────── // ── Chat log ─────────────────────────────────────────────────────────────
let mut chat_lines = build_chat_lines(&state.transcript_entries, state.color_enabled); let 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 { let chat_border_style = if state.focus == Focus::ChatLog {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
@ -447,15 +300,8 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
Style::default() Style::default()
}; };
let search_hint = if !state.search_active.is_empty() {
" · n/N match"
} else {
""
};
let chat_block = Block::default() let chat_block = Block::default()
.title(format!( .title(" Transcript [↑/↓ scroll · ←/→ h-scroll · Tab focus · Esc back · ? help] ")
" Transcript [↑/↓ scroll · Spc/PgDn page · t search{search_hint} · Tab · Esc · ?] "
))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(chat_border_style); .border_style(chat_border_style);
@ -511,9 +357,6 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.block(subagents_block); .block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]); f.render_widget(subagents_paragraph, body_chunks[1]);
// ── Search bar ───────────────────────────────────────────────────────────
render_search_bar(f, chunks[2], state);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -531,18 +374,12 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
return false; 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 { match key.code {
// Toggle focus: ChatLog → SubagentsPanel → SearchInput → ChatLog. // Toggle focus between ChatLog and SubagentsPanel.
KeyCode::Tab => { KeyCode::Tab => {
state.focus = match state.focus { state.focus = match state.focus {
Focus::ChatLog => Focus::SubagentsPanel, Focus::ChatLog => Focus::SubagentsPanel,
Focus::SubagentsPanel => Focus::SearchInput, Focus::SubagentsPanel | Focus::FilterInput => Focus::ChatLog,
Focus::SearchInput | Focus::FilterInput => Focus::ChatLog,
}; };
true true
} }
@ -561,40 +398,13 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state.show_help = true; state.show_help = true;
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. // Directional keys are routed based on the focused panel.
KeyCode::Up | KeyCode::Char('k') => match state.focus { KeyCode::Up | KeyCode::Char('k') => match state.focus {
Focus::SubagentsPanel => { Focus::SubagentsPanel => {
state.subagent_selected = state.subagent_selected.saturating_sub(1); state.subagent_selected = state.subagent_selected.saturating_sub(1);
true true
} }
Focus::ChatLog | Focus::FilterInput | Focus::SearchInput => { Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1); state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true true
} }
@ -608,28 +418,11 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
} }
true true
} }
Focus::ChatLog | Focus::FilterInput | Focus::SearchInput => { Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1); state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true true
} }
}, },
// Page scrolling — always acts on the chat log, matching less/more.
// Shift+Space must come before the plain-Space arm so the guard fires first.
KeyCode::Char(' ') if key.modifiers.contains(KeyModifiers::SHIFT) => {
let page = (state.transcript_page_height as usize).max(1);
state.transcript_scroll = state.transcript_scroll.saturating_sub(page);
true
}
KeyCode::PageUp => {
let page = (state.transcript_page_height as usize).max(1);
state.transcript_scroll = state.transcript_scroll.saturating_sub(page);
true
}
KeyCode::PageDown | KeyCode::Char(' ') => {
let page = (state.transcript_page_height as usize).max(1);
state.transcript_scroll = state.transcript_scroll.saturating_add(page);
true
}
// Horizontal scroll — only meaningful in ChatLog. // Horizontal scroll — only meaningful in ChatLog.
KeyCode::Left | KeyCode::Char('h') => { KeyCode::Left | KeyCode::Char('h') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1); state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1);
@ -659,53 +452,6 @@ 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 // Data loading
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1160,14 +906,12 @@ mod tests {
} }
#[test] #[test]
fn tab_cycles_focus() { fn tab_toggles_focus() {
let mut state = transcript_state(); let mut state = transcript_state();
assert_eq!(state.focus, Focus::ChatLog); assert_eq!(state.focus, Focus::ChatLog);
handle_transcript_event(press(KeyCode::Tab), &mut state); handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::SubagentsPanel); assert_eq!(state.focus, Focus::SubagentsPanel);
handle_transcript_event(press(KeyCode::Tab), &mut state); 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); assert_eq!(state.focus, Focus::ChatLog);
} }
@ -1203,218 +947,6 @@ mod tests {
assert!(state.show_help); assert!(state.show_help);
} }
fn press_with_mod(code: KeyCode, mods: KeyModifiers) -> Event {
Event::Key(KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
#[test]
fn space_scrolls_down_by_page() {
let mut state = transcript_state();
state.transcript_page_height = 20;
handle_transcript_event(press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.transcript_scroll, 20);
}
#[test]
fn pagedown_scrolls_down_by_page() {
let mut state = transcript_state();
state.transcript_page_height = 15;
handle_transcript_event(press(KeyCode::PageDown), &mut state);
assert_eq!(state.transcript_scroll, 15);
}
#[test]
fn shift_space_scrolls_up_by_page() {
let mut state = transcript_state();
state.transcript_page_height = 10;
state.transcript_scroll = 25;
handle_transcript_event(
press_with_mod(KeyCode::Char(' '), KeyModifiers::SHIFT),
&mut state,
);
assert_eq!(state.transcript_scroll, 15);
}
#[test]
fn pageup_scrolls_up_by_page() {
let mut state = transcript_state();
state.transcript_page_height = 10;
state.transcript_scroll = 25;
handle_transcript_event(press(KeyCode::PageUp), &mut state);
assert_eq!(state.transcript_scroll, 15);
}
#[test]
fn pageup_clamps_at_zero() {
let mut state = transcript_state();
state.transcript_page_height = 20;
state.transcript_scroll = 5;
handle_transcript_event(press(KeyCode::PageUp), &mut state);
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();
state.transcript_page_height = 0;
handle_transcript_event(press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.transcript_scroll, 1);
}
#[test] #[test]
fn unhandled_key_not_consumed() { fn unhandled_key_not_consumed() {
let mut state = transcript_state(); let mut state = transcript_state();

@ -80,8 +80,6 @@ pub enum Focus {
SubagentsPanel, SubagentsPanel,
/// The filter input bar at the bottom of the session-list screen. /// The filter input bar at the bottom of the session-list screen.
FilterInput, FilterInput,
/// The search input bar at the bottom of the transcript screen.
SearchInput,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -111,11 +109,6 @@ pub struct AppState {
pub transcript_scroll: usize, pub transcript_scroll: usize,
/// Horizontal scroll offset (columns from the left edge of the chat log). /// Horizontal scroll offset (columns from the left edge of the chat log).
pub transcript_h_scroll: usize, pub transcript_h_scroll: usize,
/// Height of the visible chat-log content area in rows (set each frame).
///
/// Used by Space / PageDown / PageUp to scroll by exactly one page.
/// Zero until the first frame is drawn.
pub transcript_page_height: u16,
// ── Sub-agents panel ──────────────────────────────────────────────────── // ── Sub-agents panel ────────────────────────────────────────────────────
/// Sub-agent references for the currently viewed session. /// Sub-agent references for the currently viewed session.
@ -123,18 +116,6 @@ pub struct AppState {
/// Index of the highlighted row in the sub-agents panel. /// Index of the highlighted row in the sub-agents panel.
pub subagent_selected: usize, 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 ─────────────────────────────────────────────────────────────── // ── Focus ───────────────────────────────────────────────────────────────
/// Which panel currently holds keyboard focus. /// Which panel currently holds keyboard focus.
pub focus: Focus, pub focus: Focus,
@ -226,11 +207,6 @@ impl AppState {
transcript_entries: Vec::new(), transcript_entries: Vec::new(),
transcript_scroll: 0, transcript_scroll: 0,
transcript_h_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(), subagents: Vec::new(),
subagent_selected: 0, subagent_selected: 0,
focus: Focus::default(), focus: Focus::default(),
@ -258,10 +234,6 @@ impl AppState {
self.transcript_entries.clear(); self.transcript_entries.clear();
self.transcript_scroll = 0; self.transcript_scroll = 0;
self.transcript_h_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.subagents.clear();
self.subagent_selected = 0; self.subagent_selected = 0;
self.focus = Focus::ChatLog; self.focus = Focus::ChatLog;
@ -283,10 +255,6 @@ impl AppState {
self.transcript_entries.clear(); self.transcript_entries.clear();
self.transcript_scroll = 0; self.transcript_scroll = 0;
self.transcript_h_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; self.focus = Focus::ChatLog;
} }
@ -300,10 +268,6 @@ impl AppState {
self.transcript_entries.clear(); self.transcript_entries.clear();
self.transcript_scroll = 0; self.transcript_scroll = 0;
self.transcript_h_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.subagents.clear();
self.subagent_selected = 0; self.subagent_selected = 0;
self.focus = Focus::ChatLog; self.focus = Focus::ChatLog;

Loading…
Cancel
Save