From 0581431dd72ad74ad0911136c64bb036b96215ee Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 30 Mar 2026 09:59:46 -0700 Subject: [PATCH] feat(tui): add transcript screen with stats header and chat log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders 4-row stats header (session ID, model, tokens, duration, tools) above a scrollable chat log (Paragraph::scroll). Thinking blocks filtered, tool results truncated to 200 chars. Handles scroll, Tab focus toggle, Esc→back, q→quit dialog. Loads data lazily in event loop. Closes claudbg-rudq Co-Authored-By: Claude Sonnet 4.6 --- ...script-screen-chat-log-and-stats-header.md | 4 +- src/tui/run.rs | 54 +- src/tui/screens/mod.rs | 1 + src/tui/screens/transcript.rs | 839 ++++++++++++++++++ 4 files changed, 885 insertions(+), 13 deletions(-) create mode 100644 src/tui/screens/transcript.rs diff --git a/.beans/claudbg-rudq--tui-transcript-screen-chat-log-and-stats-header.md b/.beans/claudbg-rudq--tui-transcript-screen-chat-log-and-stats-header.md index 66b8a66..b466426 100644 --- a/.beans/claudbg-rudq--tui-transcript-screen-chat-log-and-stats-header.md +++ b/.beans/claudbg-rudq--tui-transcript-screen-chat-log-and-stats-header.md @@ -1,11 +1,11 @@ --- # claudbg-rudq title: 'TUI: transcript screen — chat log and stats header' -status: todo +status: in-progress type: feature priority: normal created_at: 2026-03-30T04:46:56Z -updated_at: 2026-03-30T04:49:03Z +updated_at: 2026-03-30T16:53:51Z parent: claudbg-i6l2 blocked_by: - claudbg-ut9q diff --git a/src/tui/run.rs b/src/tui/run.rs index 704a441..9444ba4 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -24,6 +24,10 @@ use crate::error::Result; use crate::tui::modals::help_modal::{handle_help_modal_event, render_help_modal}; use crate::tui::modals::quit_dialog::{handle_quit_dialog_event, render_quit_dialog}; use crate::tui::screens::session_list::{handle_session_list_event, render_session_list}; +use crate::tui::screens::transcript::{ + handle_transcript_event, load_transcript_for_agent, load_transcript_for_session, + render_transcript, +}; use crate::tui::state::{AppState, Screen}; // --------------------------------------------------------------------------- @@ -89,13 +93,8 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) { let area = frame.area(); match &state.screen { Screen::SessionList => render_session_list(frame, area, state), - // Transcript and sub-agent screens are rendered by future tickets. Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => { - // Placeholder until transcript screen is implemented. - let block = ratatui::widgets::Block::default() - .title(" Transcript ") - .borders(ratatui::widgets::Borders::ALL); - frame.render_widget(block, area); + render_transcript(frame, area, state); } } @@ -130,8 +129,9 @@ fn handle_event(event: Event, state: &mut AppState) { let consumed = match &state.screen { Screen::SessionList => handle_session_list_event(event.clone(), state), - // Future screens will add their own handlers here. - Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => false, + Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => { + handle_transcript_event(event.clone(), state) + } }; if consumed { @@ -151,6 +151,34 @@ fn handle_event(event: Event, state: &mut AppState) { } } +// --------------------------------------------------------------------------- +// Transcript data loading +// --------------------------------------------------------------------------- + +/// Load transcript entries into `state` if we are on a transcript screen and +/// the entries have not been loaded yet. +/// +/// Called once per event loop iteration, after event handling. Because +/// discovery and file I/O can be slow, this is a no-op when entries are +/// already populated. +fn maybe_load_transcript(state: &mut AppState) { + if !state.transcript_entries.is_empty() { + return; + } + match state.screen.clone() { + Screen::Transcript { session_id } => { + load_transcript_for_session(&session_id, state); + } + Screen::SubagentTranscript { + parent_session_id, + agent_id, + } => { + load_transcript_for_agent(&parent_session_id, &agent_id, state); + } + Screen::SessionList => {} + } +} + // --------------------------------------------------------------------------- // Main event loop // --------------------------------------------------------------------------- @@ -166,6 +194,9 @@ pub fn run_tui() -> Result<()> { let mut state = AppState::new(); loop { + // Load transcript data lazily when entering a transcript screen. + maybe_load_transcript(&mut state); + guard.terminal.draw(|f| render(f, &state))?; if event::poll(Duration::from_millis(50))? { @@ -210,13 +241,14 @@ mod tests { assert!(!state.should_quit); } - /// Pressing `q` on the transcript screen (global fallback) sets `should_quit`. + /// Pressing `q` on the transcript screen shows the quit dialog (same as session list). #[test] - fn q_on_transcript_sets_should_quit() { + fn q_on_transcript_shows_quit_dialog() { let mut state = AppState::new(); state.enter_transcript("some-session"); handle_event(key_press(KeyCode::Char('q')), &mut state); - assert!(state.should_quit); + assert!(state.show_quit_dialog); + assert!(!state.should_quit); } /// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList). diff --git a/src/tui/screens/mod.rs b/src/tui/screens/mod.rs index fd9edb8..dc83281 100644 --- a/src/tui/screens/mod.rs +++ b/src/tui/screens/mod.rs @@ -3,3 +3,4 @@ //! Each sub-module owns the rendering and event-handling logic for one screen. pub mod session_list; +pub mod transcript; diff --git a/src/tui/screens/transcript.rs b/src/tui/screens/transcript.rs new file mode 100644 index 0000000..f363906 --- /dev/null +++ b/src/tui/screens/transcript.rs @@ -0,0 +1,839 @@ +//! 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}; +use ratatui::layout::{Constraint, 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. +pub fn build_chat_lines(entries: &[RawEntry]) -> 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(); + + match &msg.content { + None => {} + Some(MessageContent::Text(t)) => { + let prefix = 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() { + let line_text = if first { + first = false; + format!("{prefix}{src_line}") + } else { + // Indent continuation lines by the prefix width. + format!("{}{src_line}", " ".repeat(prefix.len())) + }; + lines.push(Line::from(line_text)); + } + if first { + // Empty text — still emit the prefix. + lines.push(Line::from(prefix)); + } + } + Some(MessageContent::Blocks(blocks)) => { + for block in blocks { + match block { + ContentBlock::Thinking { .. } => { + // Hidden by default. + } + ContentBlock::Text { text } => { + let prefix = format!("[{role}]: "); + let mut first = true; + for src_line in text.lines() { + let line_text = if first { + first = false; + format!("{prefix}{src_line}") + } else { + format!("{}{src_line}", " ".repeat(prefix.len())) + }; + lines.push(Line::from(line_text)); + } + if first { + lines.push(Line::from(prefix)); + } + } + 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 { "" }; + lines.push(Line::from(Span::styled( + format!("[tool: {name}] {truncated}{ellipsis}"), + Style::default().fg(Color::Cyan), + ))); + } + 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 is_error.unwrap_or(false) { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }; + lines.push(Line::from(Span::styled( + format!("[tool_result{err_flag}]: {truncated}{ellipsis}"), + style, + ))); + } + ContentBlock::Image { .. } => { + lines.push(Line::from(Span::styled( + "[image]".to_string(), + Style::default().fg(Color::Yellow), + ))); + } + ContentBlock::Unknown => { + lines.push(Line::from("[unknown block]".to_string())); + } + } + } + } + } + } + + lines +} + +/// 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 transcript screen onto `area` of the given frame. +/// +/// Layout: +/// - Top 4 lines: stats header (inside a bordered block). +/// - Remaining lines: scrollable chat log. +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) | chat log (min). + // Header block: 2 content lines + 2 border lines = 4 rows total. + let header_height: u16 = 4; + let chunks = Layout::vertical([ + Constraint::Length(header_height), + Constraint::Min(1), + ]) + .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 ───────────────────────────────────────────────────────────── + let chat_lines = build_chat_lines(&state.transcript_entries); + + let border_style = if state.focus == Focus::ChatLog { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let chat_block = Block::default() + .title(" Transcript [↑/↓ scroll · ←/→ h-scroll · Tab focus · Esc back · ? help] ") + .borders(Borders::ALL) + .border_style(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, chunks[1]); +} + +// --------------------------------------------------------------------------- +// 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; + } + + match key.code { + // Vertical scroll. + KeyCode::Up | KeyCode::Char('k') => { + state.transcript_scroll = state.transcript_scroll.saturating_sub(1); + true + } + KeyCode::Down | KeyCode::Char('j') => { + state.transcript_scroll = state.transcript_scroll.saturating_add(1); + true + } + // Horizontal scroll. + 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 + } + // Toggle focus between ChatLog and SubagentsPanel. + KeyCode::Tab => { + state.focus = match state.focus { + Focus::ChatLog => Focus::SubagentsPanel, + Focus::SubagentsPanel => 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 + } + _ => false, + } +} + +// --------------------------------------------------------------------------- +// 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; block on the async reader using the current runtime. + let handle = tokio::runtime::Handle::current(); + let file_path = sr.file_path.clone(); + if let Ok(entries) = handle.block_on(crate::parser::reader::read_session_file(&file_path)) { + 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 handle = tokio::runtime::Handle::current(); + let file_path = ar.file_path.clone(); + if let Ok(entries) = handle.block_on(crate::parser::reader::read_session_file(&file_path)) { + 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(&[]); + assert!(lines.is_empty()); + } + + #[test] + fn chat_lines_user_text() { + let entry = user_text_entry("Hello, Claude!"); + let lines = build_chat_lines(&[entry]); + 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]); + 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]); + 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]); + // 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]); + 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]); + 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]); + 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_toggles_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::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); + } + + #[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); + } +}