feat(tui): add transcript screen with stats header and chat log

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 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 523bf4b89c
commit 0581431dd7

@ -1,11 +1,11 @@
--- ---
# claudbg-rudq # claudbg-rudq
title: 'TUI: transcript screen — chat log and stats header' title: 'TUI: transcript screen — chat log and stats header'
status: todo status: in-progress
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-30T04:46:56Z created_at: 2026-03-30T04:46:56Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:53:51Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q

@ -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::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::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::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}; use crate::tui::state::{AppState, Screen};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -89,13 +93,8 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) {
let area = frame.area(); let area = frame.area();
match &state.screen { match &state.screen {
Screen::SessionList => render_session_list(frame, area, state), Screen::SessionList => render_session_list(frame, area, state),
// Transcript and sub-agent screens are rendered by future tickets.
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => { Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
// Placeholder until transcript screen is implemented. render_transcript(frame, area, state);
let block = ratatui::widgets::Block::default()
.title(" Transcript ")
.borders(ratatui::widgets::Borders::ALL);
frame.render_widget(block, area);
} }
} }
@ -130,8 +129,9 @@ fn handle_event(event: Event, state: &mut AppState) {
let consumed = match &state.screen { let consumed = match &state.screen {
Screen::SessionList => handle_session_list_event(event.clone(), state), Screen::SessionList => handle_session_list_event(event.clone(), state),
// Future screens will add their own handlers here. Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => false, handle_transcript_event(event.clone(), state)
}
}; };
if consumed { 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 // Main event loop
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -166,6 +194,9 @@ pub fn run_tui() -> Result<()> {
let mut state = AppState::new(); let mut state = AppState::new();
loop { loop {
// Load transcript data lazily when entering a transcript screen.
maybe_load_transcript(&mut state);
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))? {
@ -210,13 +241,14 @@ mod tests {
assert!(!state.should_quit); 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] #[test]
fn q_on_transcript_sets_should_quit() { fn q_on_transcript_shows_quit_dialog() {
let mut state = AppState::new(); let mut state = AppState::new();
state.enter_transcript("some-session"); state.enter_transcript("some-session");
handle_event(key_press(KeyCode::Char('q')), &mut state); 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). /// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList).

@ -3,3 +3,4 @@
//! Each sub-module owns the rendering and event-handling logic for one screen. //! Each sub-module owns the rendering and event-handling logic for one screen.
pub mod session_list; pub mod session_list;
pub mod transcript;

@ -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: <short_id> Model: <model> Tokens: in=<N> out=<N> Duration: <dur>`
/// Line 2: `Tools: Name×count, Name×count, …` (omitted if no tool calls)
fn build_header_lines(session_id: &str, entries: &[RawEntry]) -> Vec<Line<'static>> {
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::<Vec<_>>()
.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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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);
}
}
Loading…
Cancel
Save