mod welcome;
use super::{
hotkey_line_with_theme, render_scroll_view_persistent_with_metrics_and_theme,
transcript_scroll_view_content_width,
};
#[cfg(test)]
use crate::tui::layout::TuiPane;
use crate::tui::state::MissionControlState;
#[cfg(test)]
use crate::tui::theme::MissionControlTheme;
#[cfg(test)]
use ratatui::text::Span;
use ratatui::{
Frame,
layout::Rect,
text::Line,
widgets::{Block, Paragraph, Wrap},
};
#[cfg(test)]
use unicode_segmentation::UnicodeSegmentation;
pub(super) fn draw_transcript(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
frame.render_widget(Block::default().style(theme.pane()), area);
let title_area = super::transcript_title_area_for_pane(area);
let text_area = super::transcript_text_area_for_pane(area);
if title_area.width > 0 && title_area.height > 0 {
let toggle_width = u16::try_from(PANEL_TOGGLE_LABEL.len())
.unwrap_or(u16::MAX)
.min(title_area.width);
let session_area = Rect::new(
title_area.x,
title_area.y,
title_area.width.saturating_sub(toggle_width),
title_area.height,
);
let toggle_area = Rect::new(
title_area.x.saturating_add(session_area.width),
title_area.y,
toggle_width,
title_area.height,
);
frame.render_widget(
Paragraph::new(transcript_title(state)).style(theme.pane()),
session_area,
);
frame.render_widget(
Paragraph::new(panel_toggle_title(state)).style(theme.pane()),
toggle_area,
);
}
let branch_y = title_area.y.saturating_add(1);
if branch_y < area.bottom() {
frame.render_widget(
Paragraph::new(Line::styled(
format!(" {}", super::header::branch_text(state)),
theme.muted(false),
))
.style(theme.pane()),
Rect::new(title_area.x, branch_y, title_area.width, 1),
);
}
if state.transcript.is_empty() {
welcome::draw_welcome(frame, text_area, state);
return;
}
let visible = crate::tui::transcript_projection::visible_transcript_lines(
state,
transcript_scroll_view_content_width(text_area).max(1),
text_area.height,
);
state
.scroll_views
.transcript
.borrow_mut()
.set_offset(ratatui::layout::Position::new(
0,
crate::tui::transcript::usize_to_u16_saturating(visible.scroll_offset),
));
render_scroll_view_persistent_with_metrics_and_theme(
frame,
text_area,
transcript_scroll_view_content_width(text_area),
0,
visible
.scroll_offset
.saturating_add(text_area.height as usize)
.max(visible.scroll_offset.saturating_add(visible.lines.len()))
.try_into()
.unwrap_or(u16::MAX),
visible.scrollbar.content_length,
visible.scrollbar.position,
visible.scrollbar.viewport_length,
Paragraph::new(visible.lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
&state.scroll_views.transcript,
theme,
);
}
const PANEL_TOGGLE_LABEL: &str = "[Alt-A] Show/Hide Panel";
fn transcript_title(state: &MissionControlState) -> Line<'static> {
Line::styled(
format!(" Session: {} ", state.footer_session),
state.theme.title(state.is_transcript_focused()),
)
}
fn panel_toggle_title(state: &MissionControlState) -> Line<'static> {
hotkey_line_with_theme(
state.theme,
PANEL_TOGGLE_LABEL,
state.theme.title(state.is_transcript_focused()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::render::test_support::find_text;
use crate::tui::transcript_projection::visual_line_with_selection_for_test;
use ratatui::backend::TestBackend;
#[test]
fn branch_header_sits_below_session_with_blank_row_before_content() {
let state = MissionControlState {
footer_session: "session-a".into(),
footer_cwd: "/repo".into(),
footer_git_branch: Some("main".into()),
transcript: vec!["transcript body".to_string()].into(),
..Default::default()
};
let area = Rect::new(0, 0, 80, 12);
let mut terminal = ratatui::Terminal::new(TestBackend::new(80, 12)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let session = find_text(buffer, "Session: session-a").unwrap();
let branch = find_text(buffer, "Branch: main").unwrap();
assert!(find_text(buffer, "Cwd:").is_none());
assert!(find_text(buffer, "/repo").is_none());
assert_eq!(branch, (session.0, session.1 + 1));
assert_eq!(Some(buffer[branch].fg), state.theme.muted(false).fg);
for x in area.x..area.right() {
assert_eq!(buffer[(x, branch.1 + 1)].symbol(), " ");
}
let body = find_text(buffer, "transcript body").unwrap();
assert!(body.1 > branch.1 + 1);
}
#[test]
fn draw_transcript_renders_read_with_stacked_background_bands() {
let mut state = MissionControlState::default();
let id = crate::output::ActivityId::new("call_read");
state
.transcript
.push_back("tool: ✓ read • done".to_string());
state.transcript.link_activity(
0,
crate::tui::state::TranscriptActivityLink {
activity_id: id.clone(),
tool_name: "read".to_string(),
},
);
state.apply_activity_event(crate::output::ActivityEvent::ToolResultDetail {
id,
detail: crate::output::ToolActivityDetail {
tool_name: "read".into(),
label: "read src/lib.rs".into(),
params: serde_json::json!({"path":"src/lib.rs"}),
metadata: serde_json::json!({"hashline_seen_lines":[1]}),
status: crate::output::ActivityStatus::Success,
output: "contents".into(),
applied_diff: None,
},
});
let backend = TestBackend::new(80, 12);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 12), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let header = find_text(buffer, "✓ Read").expect("read header");
let body = find_text(buffer, "src/lib.rs").expect("read body");
assert_eq!(
buffer[header].bg,
MissionControlTheme::default().surface_panel_alt()
);
assert_eq!(
buffer[body].bg,
MissionControlTheme::default().surface_panel()
);
assert!(find_text(buffer, "[view-activity]").is_none());
assert!(find_text(buffer, "┌").is_none());
assert!(find_text(buffer, "└").is_none());
}
#[test]
fn visible_transcript_lines_returns_viewport_subset() {
let mut state = MissionControlState::default();
for index in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("prompt {index}"),
});
}
let width = 80;
let visible_rows = 4;
let all_lines = state.cached_transcript_lines(width);
let visible = crate::tui::transcript_projection::visible_transcript_lines(
&state,
width,
visible_rows,
);
assert!(all_lines.len() > visible.lines.len());
assert!(visible.lines.len() <= usize::from(visible_rows));
assert_eq!(visible.scroll_offset, 0);
let text = visible
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(text.contains("prompt 29"), "{text}");
}
#[test]
fn draw_transcript_tail_window_does_not_project_all_entries() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
crate::tui::transcript_cards::reset_project_entry_count_for_test();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 7), &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"tail draw should project only visible card window, projected {projected}"
);
assert!(find_text(terminal.backend().buffer(), "entry 499").is_some());
}
#[test]
fn draw_transcript_older_window_does_not_project_all_entries() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
state.set_scroll_offset(&state.scroll_views.transcript, 4);
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
crate::tui::transcript_cards::reset_project_entry_count_for_test();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 6), &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"scrollback draw should project only visible card window, projected {projected}"
);
let buffer = terminal.backend().buffer();
assert!(find_text(buffer, "entry 0").is_none());
}
#[test]
fn first_transcript_scroll_up_does_not_project_full_scrollback() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
crate::tui::transcript_cards::reset_project_entry_count_for_test();
assert!(state.scroll_transcript_up_by(3, 4, 80));
assert_eq!(
crate::tui::transcript_cards::project_entry_count_for_test(),
0
);
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 6), &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"first scroll draw should stay windowed, projected {projected}"
);
}
#[test]
fn transcript_widget_offset_is_projection_origin_not_tail_distance() {
let mut state = MissionControlState::default();
for index in 0..20 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
state.set_scroll_offset(&state.scroll_views.transcript, 3);
let mut terminal = ratatui::Terminal::new(TestBackend::new(80, 8)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 6), &state))
.unwrap();
assert_eq!(state.transcript_scroll_offset(), 3);
assert_eq!(state.scroll_views.transcript.borrow().offset().y, 0);
}
#[test]
fn selection_overlay_keeps_transcript_projection_windowed() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
state.start_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(0, 0, 0),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(8, 0, 8),
);
crate::tui::transcript_cards::reset_project_entry_count_for_test();
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, Rect::new(0, 0, 80, 6), &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"selection draw should overlay visible window only, projected {projected}"
);
}
#[test]
fn cold_selection_overlay_for_visible_tail_row_stays_windowed() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
let area = Rect::new(0, 0, 80, 7);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let visible_visual = crate::tui::transcript_cards::visible_scrollback_visual_lines(
&state,
transcript_scroll_view_content_width(text_area).max(1),
text_area.height,
0,
);
let (row, col) = visible_line_position_for_text(
&visible_visual.lines,
visible_visual.scroll_offset,
transcript_scroll_view_content_width(text_area).max(1),
"entry 499",
)
.expect("latest entry visible before render");
state.start_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(10_000, row, col),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(10_005, row, col + 5),
);
crate::tui::transcript_cards::reset_project_entry_count_for_test();
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"cold selection draw should not project hidden prefix, projected {projected}"
);
let buffer = terminal.backend().buffer();
let row_start = find_text(buffer, "entry 499").expect("latest entry visible");
assert!(
(0..5).any(|offset| buffer[(row_start.0 + offset, row_start.1)].bg
== MissionControlTheme::default().surface_selection()),
"latest entry row should contain selected cells"
);
}
#[test]
fn selection_overlay_uses_global_copy_offsets_for_visible_tail_window() {
let mut state = MissionControlState::default();
for index in 0..500 {
state
.transcript
.push_back(format!("session: entry {index}"));
}
let area = Rect::new(0, 0, 80, 8);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let width = transcript_scroll_view_content_width(text_area).max(1);
let visible = crate::tui::transcript_projection::visible_transcript_lines(
&state,
width,
text_area.height,
);
let visible_row = visible
.lines
.iter()
.position(|line| {
crate::tui::transcript_cards::rendered_line_text(line).contains("entry 499")
})
.expect("latest visible body row");
let row_text =
crate::tui::transcript_cards::rendered_line_text(&visible.lines[visible_row]);
let column = row_text.find("entry 499").expect("entry column") as u16;
let global_row = crate::tui::transcript_cards::visual_lines(&state, width)
.iter()
.position(|line| {
crate::tui::transcript_cards::rendered_line_text(&line.line).contains("entry 499")
})
.expect("latest global body row");
let render_scroll = state.transcript_render_scroll(text_area.height, width);
let rendered_row = global_row.saturating_sub(render_scroll) as u16;
let start = state
.transcript_text_position_for_cell(rendered_row, column, text_area.height, width)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(rendered_row, column + 5, text_area.height, width)
.expect("selection end");
assert!(
start.byte > 0,
"selection must not start at first global byte"
);
state.start_selection(TuiPane::Transcript, start);
state.update_selection(TuiPane::Transcript, end);
crate::tui::transcript_cards::reset_project_entry_count_for_test();
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let projected = crate::tui::transcript_cards::project_entry_count_for_test();
assert!(
projected < 50,
"selection draw should keep projection windowed, projected {projected}"
);
let buffer = terminal.backend().buffer();
let selected_cells = (0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.filter(|&(x, y)| {
buffer[(x, y)].bg == MissionControlTheme::default().surface_selection()
})
.collect::<Vec<_>>();
assert!(
!selected_cells.is_empty(),
"selection overlay should highlight visible cells"
);
let row_start = find_text(buffer, "entry 499").expect("latest entry visible");
assert!(
(0..9).any(|offset| buffer[(row_start.0 + offset, row_start.1)].bg
== MissionControlTheme::default().surface_selection()),
"latest entry row should contain selected cells"
);
}
#[test]
fn selection_overlay_highlights_visible_row_when_global_range_fits_local_window_len() {
let mut state = MissionControlState::default();
state.transcript.push_back("session: h".to_string());
for index in 0..2 {
state
.transcript
.push_back(format!("session: visible-row-{index} {}", "x".repeat(500)));
}
let width = 80;
let visible_rows = 18;
let visible_visual = crate::tui::transcript_cards::visible_scrollback_visual_lines(
&state,
width,
visible_rows,
0,
);
assert!(visible_visual.has_overflow_above, "overflow above required");
let visible_copy_len = copy_len_for_visual_lines(&visible_visual.lines);
let first_visible_line_row = visible_visual
.visual_start_row
.saturating_sub(visible_visual.scroll_offset);
let all_lines = crate::tui::transcript_cards::visual_lines(&state, width);
let hidden_prefix_copy_len =
copy_len_before_visual_row(&all_lines, width, first_visible_line_row);
assert!(
hidden_prefix_copy_len < visible_copy_len,
"hidden prefix copy len {hidden_prefix_copy_len} should be shorter than visible window copy len {visible_copy_len}"
);
let target = (0..2)
.map(|index| format!("visible-row-{index}"))
.find(|target| {
visible_line_position_for_text(
&visible_visual.lines,
visible_visual.scroll_offset,
width,
target,
)
.is_some()
&& visual_line_position_for_text(&all_lines, width, target)
.is_some_and(|(_, _, byte)| byte + target.len() <= visible_copy_len)
})
.expect("visible target whose global byte end fits local window length");
let (target_row, target_col) = visible_line_position_for_text(
&visible_visual.lines,
visible_visual.scroll_offset,
width,
&target,
)
.expect("target visible line in viewport");
let selection_start = visual_line_position_for_text(&all_lines, width, &target)
.map(|(_, _, byte)| byte)
.expect("target visible line in full projection");
let selection_end = selection_start + target.len();
assert!(
selection_end <= visible_copy_len,
"old local/global comparison would have treated visible selection as above viewport: selection_end {selection_end}, visible_copy_len {visible_copy_len}"
);
state.start_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(selection_start, target_row, target_col),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::selection::TextPosition::new(
selection_end,
target_row,
target_col + target.len(),
),
);
let selected = crate::tui::transcript_projection::visible_transcript_lines(
&state,
width,
visible_rows,
);
let target_line = selected
.lines
.iter()
.find(|line| crate::tui::transcript_cards::rendered_line_text(line).contains(&target))
.expect("selected target line visible");
assert!(
line_has_selection(target_line),
"visible target line should contain selection highlight"
);
}
#[test]
fn transcript_title_omits_running_prompt_preview() {
let mut state = MissionControlState::default();
state.start_running_prompt("inspect\nrepo".to_string());
let title = transcript_title(&state);
let text = title
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(text, " Session: ");
assert!(!text.contains("inspect repo"));
assert!(!text.contains("[Alt-C] to cancel"));
}
#[test]
fn panel_toggle_title_is_state_independent_and_dims_hotkey() {
for column_visible in [true, false] {
let mut state = MissionControlState::default();
if !column_visible {
state.toggle_activity_column();
}
let title = panel_toggle_title(&state);
let text = title
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(text, PANEL_TOGGLE_LABEL);
let hotkey = title
.spans
.iter()
.find(|span| span.content.as_ref() == "[Alt-A]")
.expect("panel toggle hotkey");
assert_eq!(
hotkey.style.fg,
Some(MissionControlTheme::default().text_dim())
);
}
}
#[test]
fn draw_transcript_omits_running_status_and_cancel_hint() {
let mut state = MissionControlState {
status: "running prompt…".to_string(),
..Default::default()
};
state.start_running_prompt("inspect".to_string());
let backend = TestBackend::new(80, 12);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 80, 8);
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let title = find_text(buffer, "Session:").expect("top title");
assert_eq!(title.1, area.y + 1);
assert!(find_text(buffer, "running prompt…").is_none());
assert!(find_text(buffer, "[Alt-C]").is_none());
}
#[test]
fn wide_transcript_visual_rows_match_rendered_wrapping_and_tail_at_48_and_80() {
for width in [48, 80] {
let state = long_transcript_state();
let area = Rect::new(0, 0, width + 2, 8);
let text_area =
crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let width = transcript_scroll_view_content_width(text_area).max(1);
let rendered_rows = crate::tui::transcript_cards::visual_lines(&state, width)
.iter()
.map(|line| crate::tui::transcript_cards::visual_line_rows(line, width))
.sum::<usize>();
assert_eq!(width, text_area.width.saturating_sub(2));
assert_eq!(
rendered_rows,
crate::tui::transcript_cards::visual_lines(&state, width).len(),
"width {width} should pre-wrap decorated card rows before rendering"
);
assert_eq!(state.transcript_visual_rows(width), rendered_rows);
assert_eq!(
state.transcript_render_scroll(text_area.height, width),
state.transcript_scroll_overflow(text_area.height, width)
);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
assert!(
find_text(terminal.backend().buffer(), "LATEST-LINE").is_some(),
"width {width} should keep latest assistant row visible when bottom-pinned"
);
}
}
#[test]
fn transcript_selection_preserves_band_background_and_highlights_content_only() {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "hello wide selection".to_string(),
});
let area = Rect::new(0, 0, 56, 8);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let wrap_width = transcript_scroll_view_content_width(text_area).max(1);
let body_line = crate::tui::transcript_cards::visual_lines(&state, wrap_width)
.into_iter()
.enumerate()
.find_map(|(row, line)| {
let rendered = crate::tui::transcript_cards::rendered_line_text(&line.line);
rendered.find("hello").map(|column| (row as u16, column))
})
.expect("user message row");
let start = state
.transcript_text_position_for_cell(
body_line.0,
u16::try_from(body_line.1).unwrap_or(u16::MAX),
text_area.height,
wrap_width,
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
body_line.0,
u16::try_from(body_line.1 + "hello".len()).unwrap_or(u16::MAX),
text_area.height,
wrap_width,
)
.expect("selection end");
state.start_selection(TuiPane::Transcript, start);
state.update_selection(TuiPane::Transcript, end);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let body = find_text(buffer, "User: hello").expect("body row");
assert_eq!(
buffer[(body.0 - 1, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
for offset in 0..6 {
assert_eq!(
buffer[(body.0 + offset, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
}
for offset in 6..11 {
assert_eq!(
buffer[(body.0 + offset, body.1)].bg,
MissionControlTheme::default().surface_selection()
);
}
assert_eq!(
buffer[(body.0 + 11, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
}
#[test]
fn compact_transcript_selection_highlights_content_without_border_columns() {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "hello compact selection".to_string(),
});
let area = Rect::new(0, 0, 36, 9);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let wrap_width = transcript_scroll_view_content_width(text_area).max(1);
let body_line = crate::tui::transcript_cards::visual_lines(&state, wrap_width)
.into_iter()
.enumerate()
.find_map(|(row, line)| {
let rendered = crate::tui::transcript_cards::rendered_line_text(&line.line);
rendered.find("hello").map(|column| (row as u16, column))
})
.expect("user message row");
let start = state
.transcript_text_position_for_cell(
body_line.0,
u16::try_from(body_line.1).unwrap_or(u16::MAX),
text_area.height,
wrap_width,
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
body_line.0,
u16::try_from(body_line.1 + "hello".len()).unwrap_or(u16::MAX),
text_area.height,
wrap_width,
)
.expect("selection end");
state.start_selection(TuiPane::Transcript, start);
state.update_selection(TuiPane::Transcript, end);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let body = find_text(buffer, "User: hello").expect("body row");
assert_eq!(
buffer[(body.0 - 1, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
for offset in 0..6 {
assert_eq!(
buffer[(body.0 + offset, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
}
for offset in 6..11 {
assert_eq!(
buffer[(body.0 + offset, body.1)].bg,
MissionControlTheme::default().surface_selection()
);
}
assert_eq!(
buffer[(body.0 + 11, body.1)].bg,
MissionControlTheme::default().surface_panel()
);
}
#[test]
fn compact_assistant_code_gutter_selection_renders_alpha_without_utf8_panic() {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::AssistantComplete {
text: "```text\nalpha\n```".to_string(),
});
let area = Rect::new(
0,
0,
crate::tui::transcript_cards::CARD_LAYOUT_MIN_WIDTH + 1,
crate::tui::transcript_cards::CARD_LAYOUT_MIN_WIDTH + 2,
);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let _ = crate::tui::transcript_cards::visual_lines(
&state,
transcript_scroll_view_content_width(text_area).max(1),
);
let alpha_row = crate::tui::transcript_cards::visual_lines(
&state,
transcript_scroll_view_content_width(text_area).max(1),
)
.iter()
.position(|line| {
crate::tui::transcript_cards::rendered_line_text(&line.line).contains(" │ alpha")
})
.expect("alpha row");
let start = state
.transcript_text_position_for_cell(
alpha_row as u16,
5,
text_area.height,
transcript_scroll_view_content_width(text_area).max(1),
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
alpha_row as u16,
10,
text_area.height,
transcript_scroll_view_content_width(text_area).max(1),
)
.expect("selection end");
state.start_selection(TuiPane::Transcript, start);
state.update_selection(TuiPane::Transcript, end);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let row_start = find_text(buffer, " │ alpha").expect("compact code row");
assert_ne!(
buffer[(row_start.0 + 2, row_start.1)].bg,
MissionControlTheme::default().surface_selection()
);
assert_ne!(
buffer[(row_start.0 + 3, row_start.1)].bg,
MissionControlTheme::default().surface_selection()
);
for offset in 4..9 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MissionControlTheme::default().surface_selection()
);
}
}
#[test]
fn wide_assistant_code_gutter_selection_renders_alpha_without_utf8_panic() {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::AssistantComplete {
text: "```text\nalpha\n```".to_string(),
});
let area = Rect::new(
0,
0,
crate::tui::transcript_cards::CARD_LAYOUT_MIN_WIDTH + 4,
16,
);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let wrap_width = transcript_scroll_view_content_width(text_area).max(1);
let _ = crate::tui::transcript_cards::visual_lines(&state, wrap_width);
let alpha_row = crate::tui::transcript_cards::visual_lines(&state, wrap_width)
.iter()
.position(|line| {
crate::tui::transcript_cards::rendered_line_text(&line.line).contains(" │ alpha")
})
.expect("alpha row");
let start = state
.transcript_text_position_for_cell(alpha_row as u16, 5, text_area.height, wrap_width)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(alpha_row as u16, 10, text_area.height, wrap_width)
.expect("selection end");
state.start_selection(TuiPane::Transcript, start);
state.update_selection(TuiPane::Transcript, end);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let row_start = find_text(buffer, " │ alpha").expect("code row");
for offset in [0, 2, 3] {
assert_ne!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MissionControlTheme::default().surface_selection()
);
}
for offset in 4..9 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MissionControlTheme::default().surface_selection()
);
}
}
#[test]
fn rendered_selection_slices_zwj_vs16_and_combining_mark_at_grapheme_boundaries() {
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}";
let keyboard = "\u{2328}\u{FE0F}";
let copy_text = format!("x{family}{keyboard}e\u{0301}z");
let visual_line = crate::tui::transcript_cards::TranscriptVisualLine {
status_indicators: Vec::new(),
line: Line::from(vec![Span::styled(
copy_text.clone(),
MissionControlTheme::default().pane(),
)]),
copy_text: copy_text.clone(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
hit_regions: Vec::new(),
visual_copy_column_offset: 0,
visual_copy_byte_boundaries: Vec::new(),
};
let start = "x".len();
let end = copy_text.len() - "z".len();
let selected = visual_line_with_selection_for_test(
&visual_line,
start,
end,
MissionControlTheme::default(),
);
let selected_spans = selected
.spans
.iter()
.filter(|span| span.style == MissionControlTheme::default().selection())
.map(|span| span.content.as_ref())
.collect::<Vec<_>>();
assert_eq!(selected_spans, vec![format!("{family}{keyboard}e\u{0301}")]);
for span in &selected.spans {
let content = span.content.as_ref();
assert!(
copy_text.contains(content),
"span {content:?} must come from copy text"
);
assert!(
content.is_empty()
|| content.graphemes(true).all(|grapheme| [
"x",
family,
keyboard,
"e\u{0301}",
"z"
]
.contains(&grapheme))
);
}
}
#[test]
fn visual_selection_highlights_leading_and_trailing_zero_width_graphemes() {
for (input, expected) in [("\u{0301}a", "a"), ("a\u{200b}", "a")] {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: input.to_string(),
});
let width = crate::tui::transcript_cards::CARD_LAYOUT_MIN_WIDTH;
let lines = crate::tui::transcript_cards::visual_lines(&state, width);
let (row, column) = lines
.iter()
.enumerate()
.find_map(|(row, line)| {
let rendered = crate::tui::transcript_cards::rendered_line_text(&line.line);
let a_byte = rendered.find('a')?;
Some((
row,
unicode_width::UnicodeWidthStr::width(&rendered[..a_byte]),
))
})
.expect("visible grapheme");
let line = &lines[row];
let start = state
.transcript_text_position_for_cell(
row as u16,
u16::try_from(column).unwrap_or(u16::MAX),
width,
width,
)
.expect("visible grapheme start");
let end = state
.transcript_text_position_for_cell(
row as u16,
u16::try_from(column + 1).unwrap_or(u16::MAX),
width,
width,
)
.expect("visible grapheme end");
let line_start = line.copy_byte_range.expect("copy range").0;
let selected = visual_line_with_selection_for_test(
line,
start.byte.saturating_sub(line_start),
end.byte.saturating_sub(line_start),
MissionControlTheme::default(),
);
let selected_text = selected
.spans
.iter()
.filter(|span| span.style == MissionControlTheme::default().selection())
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(selected_text, expected, "input={input:?}");
}
}
fn long_transcript_state() -> MissionControlState {
let mut state = MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "please inspect the transcript timeline with enough prompt words to wrap inside a decorated wide card".to_string(),
});
state.transcript.push_back(
"tool: ⟳ bash • running with enough tool preview words to wrap inside the bordered card display"
.to_string(),
);
state.apply_output_event(&crate::output::OutputEvent::AssistantComplete {
text: "assistant response carries enough markdown words to wrap after the wide card prefix and still finish on LATEST-LINE".to_string(),
});
state
}
fn copy_len_for_visual_lines(
lines: &[crate::tui::transcript_cards::TranscriptVisualLine],
) -> usize {
let mut len = 0usize;
let mut copy_line_seen = false;
for line in lines.iter().filter(|line| line.copyable) {
if !line.copy_continuation && copy_line_seen {
len = len.saturating_add(1);
}
copy_line_seen = true;
len = len.saturating_add(line.copy_text.len());
}
len
}
fn copy_len_before_visual_row(
lines: &[crate::tui::transcript_cards::TranscriptVisualLine],
width: u16,
row: usize,
) -> usize {
let mut len = 0usize;
let mut copy_line_seen = false;
let mut current_row = 0usize;
for line in lines {
let rows = crate::tui::transcript_cards::visual_line_rows(line, width);
if current_row.saturating_add(rows) > row {
break;
}
if line.copyable {
if !line.copy_continuation && copy_line_seen {
len = len.saturating_add(1);
}
copy_line_seen = true;
len = len.saturating_add(line.copy_text.len());
}
current_row = current_row.saturating_add(rows);
}
len
}
fn visual_line_position_for_text(
lines: &[crate::tui::transcript_cards::TranscriptVisualLine],
width: u16,
needle: &str,
) -> Option<(usize, usize, usize)> {
let mut current_row = 0usize;
for line in lines {
let rendered_text = crate::tui::transcript_cards::rendered_line_text(&line.line);
if let Some(column) = rendered_text.find(needle) {
let (line_start, _) = line.copy_byte_range?;
let copy_start = line.copy_text.find(needle)?;
return Some((current_row, column, line_start + copy_start));
}
current_row = current_row
.saturating_add(crate::tui::transcript_cards::visual_line_rows(line, width));
}
None
}
fn visible_line_position_for_text(
lines: &[crate::tui::transcript_cards::TranscriptVisualLine],
scroll_offset: usize,
width: u16,
needle: &str,
) -> Option<(usize, usize)> {
let mut current_row = -(scroll_offset as isize);
for line in lines {
let rendered_text = crate::tui::transcript_cards::rendered_line_text(&line.line);
if let Some(column) = rendered_text.find(needle)
&& current_row >= 0
{
return Some((current_row as usize, column));
}
current_row = current_row.saturating_add(
crate::tui::transcript_cards::visual_line_rows(line, width) as isize,
);
}
None
}
fn line_has_selection(line: &Line<'static>) -> bool {
line.spans
.iter()
.any(|span| span.style == MissionControlTheme::default().selection())
}
#[test]
fn transcript_scrollbar_thumb_tracks_windowed_scroll_position() {
let area = Rect::new(0, 0, 80, 21);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let scrollbar_x = text_area.x + text_area.width - 1;
let thumb_top = |state: &mut MissionControlState, offset| {
state.set_scroll_offset(&state.scroll_views.transcript, offset);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, state))
.unwrap();
(text_area.y..text_area.y + text_area.height)
.find(|&y| terminal.backend().buffer()[(scrollbar_x, y)].symbol() == "┃")
.expect("transcript scrollbar thumb")
};
let mut state = MissionControlState::default();
for index in 0..500 {
let entry = if (index / 20) % 2 == 1 {
format!("assistant: entry {index} {}", "wrapped ".repeat(80))
} else {
format!("session: entry {index}")
};
state.transcript.push_back(entry);
}
let bottom = thumb_top(&mut state, 0);
let mut previous = bottom;
let mut middle = bottom;
for offset in (100..=1_000).step_by(100) {
let current = thumb_top(&mut state, offset);
assert!(
current <= previous,
"thumb moved down while scrolling up at {offset}: {previous}, {current}"
);
previous = current;
if offset == 500 {
middle = current;
}
}
let top = thumb_top(&mut state, usize::from(u16::MAX));
assert!(
bottom > middle,
"bottom thumb should be below middle: {bottom}, {middle}"
);
assert!(
middle > top,
"middle thumb should be below top: {middle}, {top}"
);
}
#[test]
#[ignore = "release-mode TestBackend transcript measurement; run with --release --ignored --nocapture"]
fn transcript_test_backend_measurement() {
let area = Rect::new(0, 0, 120, 40);
let mut state = MissionControlState::default();
for index in 0..500 {
state.transcript.push_back(format!(
"assistant: entry {index} {}",
"wrapped transcript text ".repeat(30)
));
}
state.set_scroll_offset(&state.scroll_views.transcript, 200);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
crate::tui::transcript_cards::reset_project_entry_count_for_test();
let started = std::time::Instant::now();
for _ in 0..100 {
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
}
eprintln!(
"transcript_render backend=TestBackend physical_terminal=false frames=100 elapsed_us={} projected_cards={}",
started.elapsed().as_micros(),
crate::tui::transcript_cards::project_entry_count_for_test()
);
}
}