|
|
//! 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: <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.
|
|
|
///
|
|
|
/// When `color_enabled` is `false` all spans are rendered without colour styling.
|
|
|
pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> 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();
|
|
|
|
|
|
// 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<Span<'static>> {
|
|
|
let original_style = span.style;
|
|
|
let text = span.content.into_owned();
|
|
|
let text_lower = text.to_lowercase();
|
|
|
|
|
|
// Guard: only byte-level slice when lengths match (safe for ASCII / common Unicode).
|
|
|
if text.len() != text_lower.len() || !text_lower.contains(query_lower) {
|
|
|
return vec![Span::styled(text, original_style)];
|
|
|
}
|
|
|
|
|
|
let mut result: Vec<Span<'static>> = Vec::new();
|
|
|
let mut pos = 0usize;
|
|
|
while pos < text.len() {
|
|
|
match text_lower[pos..].find(query_lower) {
|
|
|
None => {
|
|
|
result.push(Span::styled(text[pos..].to_string(), original_style));
|
|
|
break;
|
|
|
}
|
|
|
Some(rel) => {
|
|
|
let abs = pos + rel;
|
|
|
if abs > pos {
|
|
|
result.push(Span::styled(text[pos..abs].to_string(), original_style));
|
|
|
}
|
|
|
result.push(Span::styled(
|
|
|
text[abs..abs + query_lower.len()].to_string(),
|
|
|
SEARCH_HIGHLIGHT,
|
|
|
));
|
|
|
pos = abs + query_lower.len();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
if result.is_empty() {
|
|
|
result.push(Span::styled(text, original_style));
|
|
|
}
|
|
|
result
|
|
|
}
|
|
|
|
|
|
/// Post-process rendered chat lines to highlight all case-insensitive matches
|
|
|
/// of `query`. Returns lines unchanged when `query` is empty.
|
|
|
fn apply_search_highlights(lines: Vec<Line<'static>>, query: &str) -> Vec<Line<'static>> {
|
|
|
if query.is_empty() {
|
|
|
return lines;
|
|
|
}
|
|
|
let query_lower = query.to_lowercase();
|
|
|
lines
|
|
|
.into_iter()
|
|
|
.map(|line| {
|
|
|
// Quick check: does the plain text of this line contain the query?
|
|
|
let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
|
if !plain.to_lowercase().contains(&*query_lower) {
|
|
|
return line;
|
|
|
}
|
|
|
// Rebuild spans, splitting around matches.
|
|
|
let new_spans: Vec<Span<'static>> = line
|
|
|
.spans
|
|
|
.into_iter()
|
|
|
.flat_map(|sp| split_span_with_search(sp, &query_lower))
|
|
|
.collect();
|
|
|
Line::from(new_spans)
|
|
|
})
|
|
|
.collect()
|
|
|
}
|
|
|
|
|
|
/// Return the indices of chat-log lines that contain `query` (case-insensitive).
|
|
|
///
|
|
|
/// Calls [`build_chat_lines`] to get the rendered lines, then filters by text content.
|
|
|
/// Returns an empty vec when `query` is empty.
|
|
|
pub fn find_match_lines(entries: &[RawEntry], query: &str, color_enabled: bool) -> Vec<usize> {
|
|
|
if query.is_empty() {
|
|
|
return Vec::new();
|
|
|
}
|
|
|
let query_lower = query.to_lowercase();
|
|
|
build_chat_lines(entries, color_enabled)
|
|
|
.iter()
|
|
|
.enumerate()
|
|
|
.filter(|(_, line)| {
|
|
|
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
|
text.to_lowercase().contains(&*query_lower)
|
|
|
})
|
|
|
.map(|(i, _)| i)
|
|
|
.collect()
|
|
|
}
|
|
|
|
|
|
/// Truncate a string to at most `max_chars` chars (Unicode-safe).
|
|
|
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<Line<'static>> = 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);
|
|
|
}
|
|
|
}
|