You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1678 lines
62 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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 {
// Split the input at the cursor position and insert a block cursor glyph.
let cursor = state.search_cursor.min(state.search_input.len());
let before = &state.search_input[..cursor];
let after = &state.search_input[cursor..];
let (cursor_char, after_rest) = if after.is_empty() {
("█", "")
} else {
let first_char_end = after
.char_indices()
.nth(1)
.map(|(i, _)| i)
.unwrap_or(after.len());
(&after[..first_char_end], &after[first_char_end..])
};
let spans = vec![
label,
Span::raw(before.to_string()),
Span::styled(
cursor_char,
Style::default().fg(Color::Black).bg(Color::Yellow),
),
Span::raw(after_rest.to_string()),
];
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) ──────────────────────
// Use Fill(1) + Length(30) so the chat log gets all remaining space after
// the sub-agents panel is allocated its fixed 30 columns. Fill is
// preferred over Min(0) here because it avoids any layout solver edge
// cases where Min(0) could allow content to escape its allocated rect.
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), 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();
// Highlight when this agent is the one currently being viewed
// (SubagentTranscript) OR when it is selected in the panel.
let is_viewing = matches!(
&state.screen,
crate::tui::state::Screen::SubagentTranscript { agent_id, .. }
if *agent_id == agent.agent_id
);
let is_panel_selected =
i == state.subagent_selected && state.focus == Focus::SubagentsPanel;
let text = format!(" {short_id} {agent_type}");
if is_viewing || is_panel_selected {
Line::from(Span::styled(
text,
Style::default().add_modifier(Modifier::REVERSED),
))
} else {
Line::from(text)
}
})
.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);
}
// Clear the pending-g chord state on any key that is not 'g' itself.
if !matches!(key.code, KeyCode::Char('g')) {
state.pending_g = false;
}
// Ctrl+L: request a full terminal redraw; pass through to global handler.
if key.code == KeyCode::Char('l') && key.modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
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,
};
// When switching into the search bar, position the cursor at the end.
if state.focus == Focus::SearchInput {
state.search_cursor = state.search_input.len();
}
true
}
// Navigate back: from SubagentTranscript → parent session transcript;
// from Transcript → session list.
KeyCode::Esc | KeyCode::Backspace => {
if let crate::tui::state::Screen::SubagentTranscript {
parent_session_id, ..
} = state.screen.clone()
{
state.go_back_to_transcript(&parent_session_id);
} else {
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') | KeyCode::Char('/') => {
state.focus = Focus::SearchInput;
state.search_cursor = state.search_input.len();
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
}
}
// Home → jump to top of transcript.
KeyCode::Home => {
state.transcript_scroll = 0;
true
}
// End → jump to bottom of transcript.
KeyCode::End => {
let total_lines =
build_chat_lines(&state.transcript_entries, state.color_enabled).len();
state.transcript_scroll =
total_lines.saturating_sub(state.transcript_page_height as usize);
true
}
// gg → jump to top of transcript (vim-style).
KeyCode::Char('g') => {
if std::mem::take(&mut state.pending_g) {
state.transcript_scroll = 0;
} else {
state.pending_g = true;
}
true
}
// Shift+G → jump to bottom of transcript (vim-style).
KeyCode::Char('G') => {
let total_lines =
build_chat_lines(&state.transcript_entries, state.color_enabled).len();
state.transcript_scroll =
total_lines.saturating_sub(state.transcript_page_height as usize);
true
}
_ => 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: insert at cursor position.
KeyCode::Char(c) => {
let pos = state.search_cursor;
state.search_input.insert(pos, c);
state.search_cursor += c.len_utf8();
true
}
// Backspace: remove the character *before* the cursor.
KeyCode::Backspace => {
if state.search_cursor > 0 {
let pos = state.search_cursor;
let prev = state.search_input[..pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
state.search_input.remove(prev);
state.search_cursor = prev;
}
true
}
// Left arrow: move cursor one character to the left.
KeyCode::Left => {
if state.search_cursor > 0 {
let pos = state.search_cursor;
let prev = state.search_input[..pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
state.search_cursor = prev;
}
true
}
// Right arrow: move cursor one character to the right.
KeyCode::Right => {
let pos = state.search_cursor;
if pos < state.search_input.len() {
let next = state.search_input[pos..]
.char_indices()
.nth(1)
.map(|(i, _)| pos + i)
.unwrap_or(state.search_input.len());
state.search_cursor = next;
}
true
}
// Home: jump cursor to start of input.
KeyCode::Home => {
state.search_cursor = 0;
true
}
// End: jump cursor to end of input.
KeyCode::End => {
state.search_cursor = state.search_input.len();
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_cursor = 0;
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();
state.search_cursor = 2; // cursor at end
handle_transcript_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.search_input, "h");
assert_eq!(state.search_cursor, 1);
}
#[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);
}
// ── Search cursor movement ────────────────────────────────────────────────
#[test]
fn search_char_inserts_at_cursor_not_end() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "ac".to_string();
state.search_cursor = 1; // between 'a' and 'c'
handle_transcript_event(press(KeyCode::Char('b')), &mut state);
assert_eq!(state.search_input, "abc");
assert_eq!(state.search_cursor, 2);
}
#[test]
fn search_backspace_deletes_before_cursor_not_end() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 2; // between 'b' and 'c'
handle_transcript_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.search_input, "ac");
assert_eq!(state.search_cursor, 1);
}
#[test]
fn search_backspace_at_start_does_nothing() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 0;
handle_transcript_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.search_input, "abc");
assert_eq!(state.search_cursor, 0);
}
#[test]
fn search_left_moves_cursor_back() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 2;
handle_transcript_event(press(KeyCode::Left), &mut state);
assert_eq!(state.search_cursor, 1);
}
#[test]
fn search_left_clamps_at_zero() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 0;
handle_transcript_event(press(KeyCode::Left), &mut state);
assert_eq!(state.search_cursor, 0);
}
#[test]
fn search_right_moves_cursor_forward() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 1;
handle_transcript_event(press(KeyCode::Right), &mut state);
assert_eq!(state.search_cursor, 2);
}
#[test]
fn search_right_clamps_at_end() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 3;
handle_transcript_event(press(KeyCode::Right), &mut state);
assert_eq!(state.search_cursor, 3);
}
#[test]
fn search_home_jumps_to_start() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 3;
handle_transcript_event(press(KeyCode::Home), &mut state);
assert_eq!(state.search_cursor, 0);
}
#[test]
fn search_end_jumps_to_end() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 0;
handle_transcript_event(press(KeyCode::End), &mut state);
assert_eq!(state.search_cursor, 3);
}
#[test]
fn search_esc_resets_cursor_to_zero() {
let mut state = transcript_state();
state.focus = Focus::SearchInput;
state.search_input = "abc".to_string();
state.search_cursor = 2;
handle_transcript_event(press(KeyCode::Esc), &mut state);
assert_eq!(state.search_input, "");
assert_eq!(state.search_cursor, 0);
}
}