//! Transcript screen: full-screen chat log with a stats header. //! //! Renders the session or sub-agent conversation with a fixed stats header at //! the top (session ID, model, token counts, duration, tool call summary) and //! a scrollable chat log below. use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use crate::models::session::{ContentBlock, MessageContent, RawEntry}; use crate::models::stats::compute_stats; use crate::tui::state::{AppState, Focus}; use crate::util::short_id; // --------------------------------------------------------------------------- // Stats header helpers // --------------------------------------------------------------------------- /// Format a token count in compact "Xk" / "X" form. fn fmt_k(n: u64) -> String { if n >= 1000 { format!("{}k", n / 1000) } else { n.to_string() } } /// Format a duration in milliseconds as "Xmin" / "Xs" / "Xms". fn fmt_duration(ms: u64) -> String { if ms >= 60_000 { format!("{}min", ms / 60_000) } else if ms >= 1_000 { format!("{}s", ms / 1_000) } else { format!("{ms}ms") } } /// Build the two-line stats header text from entries. /// /// Line 1: `Session: Model: Tokens: in= out= Duration: ` /// Line 2: `Tools: Name×count, Name×count, …` (omitted if no tool calls) fn build_header_lines(session_id: &str, entries: &[RawEntry]) -> Vec> { let stats = compute_stats(entries); let model = stats.model.as_deref().unwrap_or("unknown").to_string(); let short = short_id(session_id).to_string(); let line1 = format!( "Session: {} Model: {} Tokens: in={} out={} Duration: {}", short, model, fmt_k(stats.input_tokens), fmt_k(stats.output_tokens), fmt_duration(stats.duration_ms), ); let mut lines = vec![Line::from(Span::styled( line1, Style::default().add_modifier(Modifier::BOLD), ))]; if !stats.tool_calls.is_empty() { // Sort by count descending, then name ascending for determinism. let mut tool_pairs: Vec<(String, u64)> = stats.tool_calls.into_iter().collect(); tool_pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); let tools_str = tool_pairs .iter() .map(|(name, count)| format!("{name}\u{00d7}{count}")) .collect::>() .join(", "); lines.push(Line::from(format!("Tools: {tools_str}"))); } lines } // --------------------------------------------------------------------------- // Chat log helpers // --------------------------------------------------------------------------- const TOOL_RESULT_TRUNCATE: usize = 200; const TOOL_INPUT_TRUNCATE: usize = 120; /// Convert a slice of [`RawEntry`] values into ratatui [`Line`] values for display. /// /// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`] /// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars. /// /// When `color_enabled` is `false` all spans are rendered without colour styling. pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec> { let mut lines: Vec> = Vec::new(); for entry in entries { let Some(msg) = &entry.message else { continue; }; let role = msg.role.as_deref().unwrap_or("?").to_string(); // Choose a ratatui role label style based on the role name. let role_style = if color_enabled { match role.as_str() { "assistant" => Style::default().fg(Color::Rgb(255, 140, 0)), // orange "user" => Style::default().fg(Color::Rgb(170, 170, 170)), // grey _ => Style::default(), } } else { Style::default() }; match &msg.content { None => {} Some(MessageContent::Text(t)) => { let prefix_label = format!("[{role}]: "); let text = t.clone(); // Split on newlines so each source line becomes its own ratatui Line. let mut first = true; for src_line in text.lines() { if first { first = false; lines.push(Line::from(vec![ Span::styled(prefix_label.clone(), role_style), Span::raw(src_line.to_string()), ])); } else { // Indent continuation lines by the prefix width. lines.push(Line::from(format!( "{}{src_line}", " ".repeat(prefix_label.len()) ))); } } if first { // Empty text — still emit the prefix. lines.push(Line::from(Span::styled(prefix_label, role_style))); } } Some(MessageContent::Blocks(blocks)) => { for block in blocks { match block { ContentBlock::Thinking { .. } => { // Hidden by default. } ContentBlock::Text { text } => { let prefix_label = format!("[{role}]: "); let mut first = true; for src_line in text.lines() { if first { first = false; lines.push(Line::from(vec![ Span::styled(prefix_label.clone(), role_style), Span::raw(src_line.to_string()), ])); } else { lines.push(Line::from(format!( "{}{src_line}", " ".repeat(prefix_label.len()) ))); } } if first { lines.push(Line::from(Span::styled(prefix_label, role_style))); } } ContentBlock::ToolUse { name, input, .. } => { 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 tool_style = if color_enabled { Style::default().fg(Color::Cyan) } else { Style::default() }; lines.push(Line::from(Span::styled( format!("[tool: {name}] {truncated}{ellipsis}"), tool_style, ))); } ContentBlock::ToolResult { content, is_error, .. } => { 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() }); let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE); 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) } else { Style::default().fg(Color::Green) } } else { Style::default() }; lines.push(Line::from(Span::styled( format!("[tool_result{err_flag}]: {truncated}{ellipsis}"), style, ))); } ContentBlock::Image { .. } => { let img_style = if color_enabled { Style::default().fg(Color::Yellow) } else { Style::default() }; lines.push(Line::from(Span::styled("[image]".to_string(), img_style))); } ContentBlock::Unknown => { lines.push(Line::from("[unknown block]".to_string())); } } } } } } lines } // --------------------------------------------------------------------------- // Search helpers // --------------------------------------------------------------------------- /// Style applied to matched substrings in search mode. 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`]. /// /// 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> { 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(); if char_count <= max_chars { s.to_string() } else { s.chars().take(max_chars).collect() } } // --------------------------------------------------------------------------- // 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). /// - 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 { crate::tui::state::Screen::Transcript { session_id } => session_id.clone(), crate::tui::state::Screen::SubagentTranscript { agent_id, .. } => agent_id.clone(), _ => String::new(), }; // 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 ]) .split(area); // ── Stats header ──────────────────────────────────────────────────────── let header_lines = build_header_lines(&session_id, &state.transcript_entries); let header_text = Text::from(header_lines); let header_block = Block::default() .title(" Session Stats ") .borders(Borders::ALL); let header_paragraph = Paragraph::new(header_text) .block(header_block) .wrap(Wrap { trim: false }); f.render_widget(header_paragraph, chunks[0]); // ── Chat log + sub-agents panel (horizontal split) ────────────────────── let body_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(0), Constraint::Length(30)]) .split(chunks[1]); // ── Chat log ───────────────────────────────────────────────────────────── 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) } else { Style::default() }; let search_hint = if !state.search_active.is_empty() { " · n/N match" } else { "" }; let chat_block = Block::default() .title(format!( " Transcript [↑/↓ scroll · Spc/PgDn page · t search{search_hint} · Tab · Esc · ?] " )) .borders(Borders::ALL) .border_style(chat_border_style); let chat_paragraph = Paragraph::new(Text::from(chat_lines)) .block(chat_block) .scroll(( state.transcript_scroll as u16, state.transcript_h_scroll as u16, )); f.render_widget(chat_paragraph, body_chunks[0]); // ── Sub-agents panel ───────────────────────────────────────────────────── let subagents_border_style = if state.focus == Focus::SubagentsPanel { Style::default().fg(Color::Yellow) } else { Style::default() }; let subagents_block = Block::default() .title(" Sub-agents ") .borders(Borders::ALL) .border_style(subagents_border_style); let subagent_lines: Vec> = if state.subagents.is_empty() { vec![Line::from(" No sub-agents")] } else { state .subagents .iter() .enumerate() .map(|(i, agent)| { let short_id = if agent.agent_id.len() >= 8 { agent.agent_id[..8].to_string() } else { agent.agent_id.clone() }; 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}"), Style::default().fg(Color::Yellow), )) } else { Line::from(format!(" {short_id} {agent_type}")) } }) .collect() }; let subagents_paragraph = Paragraph::new(Text::from(subagent_lines)).block(subagents_block); f.render_widget(subagents_paragraph, body_chunks[1]); // ── Search bar ─────────────────────────────────────────────────────────── render_search_bar(f, chunks[2], state); } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- /// Handle a crossterm [`Event`] for the transcript screen. /// /// Returns `true` if the event was consumed, `false` otherwise. pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { let Event::Key(key) = event else { return false; }; if key.kind != KeyEventKind::Press { 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: ChatLog → SubagentsPanel → SearchInput → ChatLog. KeyCode::Tab => { state.focus = match state.focus { Focus::ChatLog => Focus::SubagentsPanel, Focus::SubagentsPanel => Focus::SearchInput, Focus::SearchInput | Focus::FilterInput => Focus::ChatLog, }; true } // Navigate back to the session list. KeyCode::Esc => { state.go_back(); true } // Show quit dialog (don't exit immediately on transcript screen). KeyCode::Char('q') | KeyCode::Char('Q') => { state.show_quit_dialog = true; true } // Show help overlay. KeyCode::Char('?') => { 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::SearchInput => { state.transcript_scroll = state.transcript_scroll.saturating_sub(1); true } }, KeyCode::Down | KeyCode::Char('j') => match state.focus { Focus::SubagentsPanel => { if !state.subagents.is_empty() { let max = state.subagents.len() - 1; state.subagent_selected = (state.subagent_selected + 1).min(max); } true } Focus::ChatLog | Focus::FilterInput | Focus::SearchInput => { state.transcript_scroll = state.transcript_scroll.saturating_add(1); 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); let total_lines = build_chat_lines(&state.transcript_entries, state.color_enabled).len(); let max_scroll = total_lines.saturating_sub(state.transcript_page_height as usize); state.transcript_scroll = state.transcript_scroll.saturating_add(page).min(max_scroll); true } // Horizontal scroll — only meaningful in ChatLog. KeyCode::Left | KeyCode::Char('h') => { state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1); true } KeyCode::Right | KeyCode::Char('l') => { state.transcript_h_scroll = state.transcript_h_scroll.saturating_add(1); true } // Enter on a selected sub-agent — navigate into its transcript. KeyCode::Enter => { if state.focus == Focus::SubagentsPanel && !state.subagents.is_empty() { let agent = state.subagents[state.subagent_selected].clone(); let parent_session_id = agent.session_id.clone(); let agent_id = agent.agent_id.clone(); state.enter_subagent_transcript(parent_session_id, agent_id); // Load the transcript for the selected agent. let agent_id_for_load = agent.agent_id.clone(); let parent_for_load = agent.session_id.clone(); load_transcript_for_agent(&parent_for_load, &agent_id_for_load, state); true } else { false } } _ => false, } } /// 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 // --------------------------------------------------------------------------- /// Load transcript entries for a session ID into `state.transcript_entries`. /// /// Walks `~/.claude/projects/` to find the JSONL file for `session_id`, then /// reads and parses it synchronously (blocking the current tokio runtime handle). /// /// Any discovery or I/O errors are silently ignored — the transcript will /// simply be empty, which the renderer handles gracefully. pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) { let sessions = match crate::parser::discovery::discover_sessions() { Ok(s) => s, Err(_) => return, }; // 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 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) { state.subagents = agents; } // Read the JSONL file. We are in a synchronous function called from within // the tokio::main async context, so Handle::block_on alone would panic. // block_in_place tells the multi-thread scheduler to move other tasks off // this thread before we block it. let file_path = sr.file_path.clone(); let entries_result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() .block_on(crate::parser::reader::read_session_file(&file_path)) }); if let Ok(entries) = entries_result { state.transcript_entries = entries; } } /// Load transcript entries for a sub-agent into `state.transcript_entries`. /// /// 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) { 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 Some(sr) = session_ref else { return }; // Find the agent under that session. let agents = match crate::parser::discovery::discover_agents_for_session(&sr.file_path) { Ok(a) => a, Err(_) => return, }; let agent_ref = agents .into_iter() .find(|a| a.agent_id == agent_id || a.agent_id.starts_with(agent_id)); let Some(ar) = agent_ref else { return }; let file_path = ar.file_path.clone(); let entries_result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() .block_on(crate::parser::reader::read_session_file(&file_path)) }); if let Ok(entries) = entries_result { state.transcript_entries = entries; } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::models::session::{ContentBlock, Message, MessageContent, RawEntry}; fn make_entry(entry_type: &str) -> RawEntry { RawEntry { entry_type: Some(entry_type.to_string()), session_id: None, parent_session_id: None, message: None, system_message: None, cwd: None, timestamp: None, duration_ms: None, extra: Default::default(), } } fn user_text_entry(text: &str) -> RawEntry { RawEntry { entry_type: Some("user".to_string()), message: Some(Message { role: Some("user".to_string()), content: Some(MessageContent::Text(text.to_string())), usage: None, model: None, stop_reason: None, }), ..make_entry("user") } } fn assistant_text_entry(text: &str) -> RawEntry { RawEntry { entry_type: Some("assistant".to_string()), message: Some(Message { role: Some("assistant".to_string()), content: Some(MessageContent::Text(text.to_string())), usage: None, model: None, stop_reason: None, }), ..make_entry("assistant") } } // ── fmt_k ─────────────────────────────────────────────────────────────── #[test] fn fmt_k_below_thousand() { assert_eq!(fmt_k(999), "999"); } #[test] fn fmt_k_exactly_thousand() { assert_eq!(fmt_k(1000), "1k"); } #[test] fn fmt_k_above_thousand() { assert_eq!(fmt_k(12_345), "12k"); } // ── fmt_duration ───────────────────────────────────────────────────────── #[test] fn fmt_duration_ms() { assert_eq!(fmt_duration(999), "999ms"); } #[test] fn fmt_duration_seconds() { assert_eq!(fmt_duration(3_500), "3s"); } #[test] fn fmt_duration_minutes() { assert_eq!(fmt_duration(125_000), "2min"); } // ── truncate_str ────────────────────────────────────────────────────────── #[test] fn truncate_str_short() { assert_eq!(truncate_str("hello", 10), "hello"); } #[test] fn truncate_str_exact() { assert_eq!(truncate_str("hello", 5), "hello"); } #[test] fn truncate_str_long() { let s = "abcdefghij"; assert_eq!(truncate_str(s, 5), "abcde"); } // ── build_header_lines ─────────────────────────────────────────────────── #[test] fn header_lines_empty_entries() { 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(); assert!(text.contains("abc12345")); assert!(text.contains("Tokens: in=0 out=0")); } #[test] fn header_lines_with_tool_calls() { let mut entry = make_entry("assistant"); entry.message = Some(Message { role: Some("assistant".to_string()), content: Some(MessageContent::Blocks(vec![ContentBlock::ToolUse { id: "t1".to_string(), name: "Bash".to_string(), input: serde_json::Value::Null, }])), usage: None, model: Some("claude-opus".to_string()), stop_reason: None, }); 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(); assert!(tools_text.contains("Bash")); } // ── build_chat_lines ───────────────────────────────────────────────────── #[test] fn chat_lines_empty_entries() { let lines = build_chat_lines(&[], false); assert!(lines.is_empty()); } #[test] fn chat_lines_user_text() { let entry = user_text_entry("Hello, Claude!"); let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[user]: Hello, Claude!")); } #[test] fn chat_lines_assistant_text() { let entry = assistant_text_entry("Here is my answer."); let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[assistant]: Here is my answer.")); } #[test] fn chat_lines_multiline_text() { let entry = user_text_entry("line1\nline2\nline3"); let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 3); } #[test] fn chat_lines_thinking_skipped() { let mut entry = make_entry("assistant"); entry.message = Some(Message { role: Some("assistant".to_string()), content: Some(MessageContent::Blocks(vec![ ContentBlock::Thinking { thinking: "hidden".to_string(), }, ContentBlock::Text { text: "visible".to_string(), }, ])), usage: None, model: None, stop_reason: None, }); let lines = build_chat_lines(&[entry], false); // Only the Text block should produce a line; Thinking is skipped. assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("visible")); assert!(!text.contains("hidden")); } #[test] fn chat_lines_tool_use() { let mut entry = make_entry("assistant"); entry.message = Some(Message { role: Some("assistant".to_string()), content: Some(MessageContent::Blocks(vec![ContentBlock::ToolUse { id: "t1".to_string(), name: "Read".to_string(), input: serde_json::json!({"file_path": "/foo/bar.rs"}), }])), usage: None, model: None, stop_reason: None, }); let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[tool: Read]")); assert!(text.contains("/foo/bar.rs")); } #[test] fn chat_lines_tool_result_truncated() { let long_content = "x".repeat(300); let mut entry = make_entry("user"); entry.message = Some(Message { role: Some("user".to_string()), content: Some(MessageContent::Blocks(vec![ContentBlock::ToolResult { tool_use_id: "t1".to_string(), content: Some(serde_json::Value::String(long_content)), is_error: Some(false), }])), usage: None, model: None, stop_reason: None, }); let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); // Should contain the ellipsis marker. assert!(text.contains('…')); } #[test] fn chat_lines_entries_without_message_skipped() { // A "system" entry with no message field should produce no chat lines. let entry = make_entry("system"); let lines = build_chat_lines(&[entry], false); assert!(lines.is_empty()); } // ── handle_transcript_event ─────────────────────────────────────────────── use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers}; fn press(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } fn release(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Release, state: KeyEventState::NONE, }) } fn transcript_state() -> AppState { let mut state = AppState::new(); state.enter_transcript("abc12345-0000-0000-0000-000000000000"); state } #[test] fn up_decrements_scroll() { let mut state = transcript_state(); state.transcript_scroll = 5; let consumed = handle_transcript_event(press(KeyCode::Up), &mut state); assert!(consumed); assert_eq!(state.transcript_scroll, 4); } #[test] fn k_decrements_scroll() { let mut state = transcript_state(); state.transcript_scroll = 3; handle_transcript_event(press(KeyCode::Char('k')), &mut state); assert_eq!(state.transcript_scroll, 2); } #[test] fn up_clamps_at_zero() { let mut state = transcript_state(); state.transcript_scroll = 0; handle_transcript_event(press(KeyCode::Up), &mut state); assert_eq!(state.transcript_scroll, 0); } #[test] fn down_increments_scroll() { let mut state = transcript_state(); state.transcript_scroll = 0; let consumed = handle_transcript_event(press(KeyCode::Down), &mut state); assert!(consumed); assert_eq!(state.transcript_scroll, 1); } #[test] fn j_increments_scroll() { let mut state = transcript_state(); state.transcript_scroll = 0; handle_transcript_event(press(KeyCode::Char('j')), &mut state); assert_eq!(state.transcript_scroll, 1); } #[test] fn left_decrements_h_scroll() { let mut state = transcript_state(); state.transcript_h_scroll = 4; let consumed = handle_transcript_event(press(KeyCode::Left), &mut state); assert!(consumed); assert_eq!(state.transcript_h_scroll, 3); } #[test] fn h_decrements_h_scroll() { let mut state = transcript_state(); state.transcript_h_scroll = 2; handle_transcript_event(press(KeyCode::Char('h')), &mut state); assert_eq!(state.transcript_h_scroll, 1); } #[test] fn left_clamps_at_zero() { let mut state = transcript_state(); state.transcript_h_scroll = 0; handle_transcript_event(press(KeyCode::Left), &mut state); assert_eq!(state.transcript_h_scroll, 0); } #[test] fn right_increments_h_scroll() { let mut state = transcript_state(); state.transcript_h_scroll = 0; let consumed = handle_transcript_event(press(KeyCode::Right), &mut state); assert!(consumed); assert_eq!(state.transcript_h_scroll, 1); } #[test] fn l_increments_h_scroll() { let mut state = transcript_state(); state.transcript_h_scroll = 0; handle_transcript_event(press(KeyCode::Char('l')), &mut state); assert_eq!(state.transcript_h_scroll, 1); } #[test] 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); } #[test] fn esc_goes_back() { let mut state = transcript_state(); let consumed = handle_transcript_event(press(KeyCode::Esc), &mut state); assert!(consumed); assert_eq!(state.screen, crate::tui::state::Screen::SessionList); } #[test] fn q_shows_quit_dialog() { let mut state = transcript_state(); let consumed = handle_transcript_event(press(KeyCode::Char('q')), &mut state); assert!(consumed); assert!(state.show_quit_dialog); assert!(!state.should_quit); } #[test] fn big_q_shows_quit_dialog() { let mut state = transcript_state(); handle_transcript_event(press(KeyCode::Char('Q')), &mut state); assert!(state.show_quit_dialog); } #[test] fn question_mark_shows_help() { let mut state = transcript_state(); let consumed = handle_transcript_event(press(KeyCode::Char('?')), &mut state); assert!(consumed); 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(); // Populate enough entries that clamping won't prevent scrolling by the full page. state.transcript_entries = (0..100) .map(|i| user_text_entry(&format!("line {i}"))) .collect(); 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(); // Populate enough entries that clamping won't prevent scrolling by the full page. state.transcript_entries = (0..100) .map(|i| user_text_entry(&format!("line {i}"))) .collect(); 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(); // Need at least one line of content so clamping allows scroll=1. state.transcript_entries = vec![user_text_entry("hello")]; state.transcript_page_height = 0; handle_transcript_event(press(KeyCode::Char(' ')), &mut state); assert_eq!(state.transcript_scroll, 1); } #[test] fn unhandled_key_not_consumed() { let mut state = transcript_state(); let consumed = handle_transcript_event(press(KeyCode::Char('x')), &mut state); assert!(!consumed); } #[test] fn key_release_not_consumed() { let mut state = transcript_state(); let consumed = handle_transcript_event(release(KeyCode::Up), &mut state); assert!(!consumed); } #[test] fn non_key_event_not_consumed() { let mut state = transcript_state(); let consumed = handle_transcript_event(Event::FocusGained, &mut state); assert!(!consumed); } }