|
|
|
|
@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|