use super::{hotkey_line, padded_title_text, render_scroll_view_persistent_with_metrics};
use crate::tui::{
PADDED_INLINE_SEPARATOR, layout::TuiPane, state::MissionControlState, theme::MATRIX_GREEN,
};
use ratatui::{
Frame,
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
use unicode_segmentation::UnicodeSegmentation;
pub(super) fn draw_transcript(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
frame.render_widget(Block::default().style(MATRIX_GREEN.pane()), area);
let text_area = super::transcript_text_area_for_pane(area);
let visible_rows = text_area.height;
let wrap_width = super::scroll_view_content_width(text_area).max(1);
let visible = state.visible_transcript_lines(wrap_width, visible_rows);
if area.height > 0 && area.width > 0 {
frame.render_widget(
Paragraph::new(transcript_title(state)).style(MATRIX_GREEN.pane()),
Rect::new(area.x, area.y, area.width, 1),
);
let mut right_labels = Vec::new();
if let Some(context_usage) = state.context_usage {
right_labels.push(context_usage_title(
context_usage.current_tokens,
context_usage.max_tokens,
));
}
if let Some(scroll_label) = transcript_scroll_label(state, wrap_width, &visible) {
right_labels.push(scroll_label);
}
if !right_labels.is_empty() {
frame.render_widget(
Paragraph::new(padded_title_text(
right_labels.join(PADDED_INLINE_SEPARATOR),
MATRIX_GREEN.title(state.is_transcript_focused()),
))
.style(MATRIX_GREEN.pane())
.alignment(Alignment::Right),
Rect::new(area.x, area.y, area.width, 1),
);
}
}
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(
frame,
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(MATRIX_GREEN.pane())
.wrap(Wrap { trim: false }),
&state.scroll_views.transcript,
);
}
fn transcript_title(state: &MissionControlState) -> Line<'static> {
hotkey_line(
format!(" 📝Session: {} ", state.footer_session),
MATRIX_GREEN.title(state.is_transcript_focused()),
)
}
fn transcript_scroll_label(
state: &MissionControlState,
wrap_width: u16,
visible: &crate::tui::transcript_cards::VisibleTranscriptLines,
) -> Option<String> {
let scrollback = state.transcript_scroll_offset();
if scrollback == 0 {
return visible.has_overflow_above.then(|| "latest".to_string());
}
if !visible.has_overflow_above {
Some("top of scrollback".to_string())
} else if wrap_width < 24 {
Some(format!("↑ {scrollback}"))
} else {
Some(format!("{scrollback} lines above latest"))
}
}
fn context_usage_title(current_tokens: usize, max_tokens: usize) -> String {
let percent = current_tokens
.saturating_mul(100)
.checked_div(max_tokens)
.map_or("--".to_string(), |percent| percent.to_string());
let percent = format!("{percent:>3}%");
format!("🗄️Context {percent}/{}", compact_context_limit(max_tokens))
}
fn compact_context_limit(tokens: usize) -> String {
if tokens < 1_000 {
return tokens.to_string();
}
format!("{}k", tokens.saturating_add(500) / 1_000)
}
#[cfg(test)]
pub(in crate::tui) fn uncached_transcript_visual_lines(
state: &MissionControlState,
width: u16,
) -> Vec<crate::tui::transcript_cards::TranscriptVisualLine> {
if let Some(selection) = state
.selection
.as_ref()
.filter(|selection| selection.pane == TuiPane::Transcript)
.and_then(|selection| selection.byte_range())
{
return selected_transcript_visual_lines(state, width, selection);
}
crate::tui::transcript_cards::visual_lines(state, width)
}
#[cfg(test)]
pub(in crate::tui) fn uncached_transcript_lines(
state: &MissionControlState,
width: u16,
) -> Vec<Line<'static>> {
uncached_transcript_visual_lines(state, width)
.into_iter()
.map(|line| line.line)
.collect()
}
#[cfg(test)]
fn selected_transcript_visual_lines(
state: &MissionControlState,
width: u16,
selection: (usize, usize),
) -> Vec<crate::tui::transcript_cards::TranscriptVisualLine> {
crate::tui::transcript_cards::visual_lines(state, width)
.into_iter()
.map(|visual_line| {
let Some((line_start, line_end)) = visual_line.copy_byte_range else {
return visual_line;
};
let start = selection.0.max(line_start);
let end = selection.1.min(line_end);
if start >= end {
visual_line
} else {
let mut visual_line = visual_line;
visual_line.line =
visual_line_with_selection(&visual_line, start - line_start, end - line_start);
visual_line
}
})
.collect()
}
pub(in crate::tui) fn selected_visible_transcript_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
) -> crate::tui::transcript_cards::VisibleTranscriptLines {
let Some(active_selection) = state
.selection
.as_ref()
.filter(|selection| selection.pane == TuiPane::Transcript)
else {
return if state.transcript_scroll_offset() == 0 {
crate::tui::transcript_cards::visible_tail_lines(state, width, visible_rows)
} else {
crate::tui::transcript_cards::visible_scrollback_lines(
state,
width,
visible_rows,
state.transcript_scroll_offset(),
)
};
};
if active_selection.byte_range().is_none() {
return if state.transcript_scroll_offset() == 0 {
crate::tui::transcript_cards::visible_tail_lines(state, width, visible_rows)
} else {
crate::tui::transcript_cards::visible_scrollback_lines(
state,
width,
visible_rows,
state.transcript_scroll_offset(),
)
};
}
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
state,
width,
visible_rows,
state.transcript_scroll_offset(),
);
let mut line_start_row = -(visible.scroll_offset as isize);
let lines = visible
.lines
.into_iter()
.map(|visual_line| {
let rows = crate::tui::transcript_cards::visual_line_rows(&visual_line, width);
let selected = visual_line_with_active_selection(
&visual_line,
active_selection,
line_start_row,
rows,
width,
);
line_start_row = line_start_row.saturating_add(rows as isize);
selected
})
.collect();
crate::tui::transcript_cards::VisibleTranscriptLines {
lines,
scroll_offset: visible.scroll_offset,
has_overflow_above: visible.has_overflow_above,
scrollbar: visible.scrollbar,
}
}
fn visual_line_with_active_selection(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
selection: &crate::tui::input::selection::ActiveSelection,
line_start_row: isize,
line_rows: usize,
width: u16,
) -> Line<'static> {
if !visual_line.copyable || line_rows == 0 {
return visual_line.line.clone();
}
let (selection_start, selection_end) = if selection.anchor.byte <= selection.current.byte {
(selection.anchor, selection.current)
} else {
(selection.current, selection.anchor)
};
let line_end_row = line_start_row.saturating_add(line_rows as isize);
if selection_end.row < line_start_row.max(0) as usize
|| selection_start.row as isize >= line_end_row
{
return visual_line.line.clone();
}
let first_row = (selection_start.row as isize).max(line_start_row);
let last_row = (selection_end.row as isize).min(line_end_row - 1);
let mut selected_copy_start = usize::MAX;
let mut selected_copy_end = 0usize;
let column_offset = usize::from(visual_line.visual_copy_column_offset);
let copy_text_len = visual_line.copy_text.len();
for row in first_row..=last_row {
let row_usize = row as usize;
let start_col = if row_usize == selection_start.row {
selection_start.col
} else {
0
};
let end_col = if row_usize == selection_end.row {
selection_end.col
} else {
usize::from(width.max(1))
};
if start_col >= end_col {
continue;
}
let row_in_line = (row - line_start_row) as usize;
let Some(start) = copy_byte_for_visual_cell(
&visual_line.copy_text,
column_offset,
copy_text_len,
row_in_line,
start_col,
width,
) else {
continue;
};
let Some(end) = copy_byte_for_visual_cell(
&visual_line.copy_text,
column_offset,
copy_text_len,
row_in_line,
end_col,
width,
) else {
continue;
};
if start < end {
selected_copy_start = selected_copy_start.min(start);
selected_copy_end = selected_copy_end.max(end);
}
}
if selected_copy_start >= selected_copy_end {
visual_line.line.clone()
} else {
visual_line_with_selection(visual_line, selected_copy_start, selected_copy_end)
}
}
fn copy_byte_for_visual_cell(
copy_text: &str,
column_offset: usize,
copy_text_len: usize,
row: usize,
column: usize,
width: u16,
) -> Option<usize> {
let position = crate::tui::transcript::text_position_for_visual_cell_with_leading_columns(
copy_text,
column_offset,
row,
column,
width.max(1),
)?;
Some(position.byte.min(copy_text_len))
}
fn visual_line_with_selection(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
copy_start: usize,
copy_end: usize,
) -> Line<'static> {
let rendered_text = crate::tui::transcript_cards::rendered_line_text(&visual_line.line);
let copy_start = copy_start.min(visual_line.copy_text.len());
let copy_end = copy_end.min(visual_line.copy_text.len());
let rendered_start_column = usize::from(visual_line.visual_copy_column_offset)
.saturating_add(display_width_for_prefix(&visual_line.copy_text, copy_start));
let rendered_end_column = usize::from(visual_line.visual_copy_column_offset)
.saturating_add(display_width_for_prefix(&visual_line.copy_text, copy_end));
let rendered_start = byte_index_for_display_column(&rendered_text, rendered_start_column);
let rendered_end = byte_index_for_display_column(&rendered_text, rendered_end_column);
line_with_rendered_selection(&visual_line.line, rendered_start, rendered_end)
}
fn display_width_for_prefix(text: &str, byte_end: usize) -> usize {
let safe_end = grapheme_boundary_at_or_before(text, byte_end.min(text.len()));
unicode_width::UnicodeWidthStr::width(&text[..safe_end])
}
fn line_with_rendered_selection(
line: &Line<'static>,
selection_start: usize,
selection_end: usize,
) -> Line<'static> {
if selection_start >= selection_end {
return line.clone();
}
let mut spans = Vec::new();
let mut span_start = 0usize;
for span in &line.spans {
let text = span.content.as_ref();
let span_end = span_start.saturating_add(text.len());
if selection_end <= span_start || selection_start >= span_end {
spans.push(span.clone());
span_start = span_end;
continue;
}
let start = grapheme_boundary_at_or_before(
text,
selection_start.saturating_sub(span_start).min(text.len()),
);
let end = grapheme_boundary_at_or_before(
text,
selection_end.saturating_sub(span_start).min(text.len()),
);
if start > 0 {
spans.push(Span::styled(text[..start].to_string(), span.style));
}
if start < end {
spans.push(Span::styled(
text[start..end].to_string(),
MATRIX_GREEN.selection(),
));
}
if end < text.len() {
spans.push(Span::styled(text[end..].to_string(), span.style));
}
span_start = span_end;
}
Line::from(spans)
}
fn byte_index_for_display_column(text: &str, column: usize) -> usize {
if column == 0 {
return 0;
}
let mut width = 0usize;
for (offset, grapheme) in text.grapheme_indices(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
let next_width = width.saturating_add(unicode_width::UnicodeWidthStr::width(grapheme));
if next_width > column {
return offset;
}
width = next_width;
if width == column {
return offset + grapheme.len();
}
}
text.len()
}
fn grapheme_boundary_at_or_before(text: &str, byte_index: usize) -> usize {
let byte_index = byte_index.min(text.len());
if byte_index == text.len() {
return text.len();
}
text.grapheme_indices(true)
.map(|(index, _)| index)
.take_while(|index| *index <= byte_index)
.last()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::render::test_support::find_text;
use ratatui::backend::TestBackend;
#[test]
fn draw_transcript_renders_tool_activity_button() {
let mut state = MissionControlState::default();
let id = crate::output::ActivityId::new("call_read");
state
.transcript
.push_back("tool: ✓ read • done".to_string());
state.transcript_activity_links.insert(
0,
crate::tui::state::TranscriptActivityLink {
activity_id: id.clone(),
tool_name: "read".to_string(),
},
);
state.apply_activity_event(crate::output::ActivityEvent::Started {
id,
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Success,
metadata: crate::output::ActivityMetadata::new("read src/lib.rs"),
});
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, 8), &state))
.unwrap();
let buffer = terminal.backend().buffer();
assert!(find_text(buffer, "🔧").is_some());
assert!(find_text(buffer, "Tool: read").is_some());
assert!(find_text(buffer, "┌─ read").is_none());
assert!(find_text(buffer, "read src/lib.rs").is_none());
let button = find_text(buffer, "[view-activity]").expect("activity button");
assert_eq!(
buffer[(button.0 + "[view-activity]".len() as u16, button.1)].symbol(),
"┘"
);
assert!(
button.0 > 40,
"button should render on right edge: {button:?}"
);
assert_eq!(buffer[button].fg, MATRIX_GREEN.green_bright);
assert!(!state.transcript_visual_text().contains("[view-activity]"));
assert!(!state.transcript_visual_text().contains("[activity]"));
}
#[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 = state.visible_transcript_lines(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, 6), &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 498").is_some());
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::input::selection::TextPosition::new(0, 0, 0),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::input::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, 6);
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,
text_area.width,
text_area.height,
0,
);
let (row, col) = visible_line_position_for_text(
&visible_visual.lines,
visible_visual.scroll_offset,
text_area.width,
"entry 499",
)
.expect("latest entry visible before render");
state.start_selection(
TuiPane::Transcript,
crate::tui::input::selection::TextPosition::new(10_000, row, col),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::input::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
== MATRIX_GREEN.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 visible = state.visible_transcript_lines(text_area.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, text_area.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, text_area.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,
text_area.width,
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
rendered_row,
column + 5,
text_area.height,
text_area.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 == MATRIX_GREEN.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
== MATRIX_GREEN.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::input::selection::TextPosition::new(
selection_start,
target_row,
target_col,
),
);
state.update_selection(
TuiPane::Transcript,
crate::tui::input::selection::TextPosition::new(
selection_end,
target_row,
target_col + target.len(),
),
);
let selected = selected_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 context_usage_title_uses_icon_labels_and_compact_limit() {
let title = context_usage_title(16274, 272000);
assert_eq!(title, "🗄️Context 5%/272k");
assert!(!title.contains("16274"));
}
#[test]
fn context_usage_title_uses_fixed_width_volatile_fields() {
assert_eq!(context_usage_title(3000, 100000), "🗄️Context 3%/100k");
assert_eq!(context_usage_title(30000, 100000), "🗄️Context 30%/100k");
assert_eq!(context_usage_title(100000, 100000), "🗄️Context 100%/100k");
assert_eq!(context_usage_title(0, 0), "🗄️Context --%/0");
assert!(!context_usage_title(16274, 272000).contains("Thinking"));
}
#[test]
fn draw_transcript_keeps_context_header_columns_stable_between_percent_widths() {
let mut state = MissionControlState::default();
state.context_usage = Some(crate::tui::state::ContextUsageState {
current_tokens: 30000,
max_tokens: 100000,
reasoning_tokens: None,
source: crate::output::ContextUsageSource::ProviderExact,
request_sequence: 1,
});
let area = Rect::new(0, 0, 80, 8);
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 context_30 = find_ascii_text(buffer, "Context").expect("30% context label");
let percent_30 = find_ascii_text(buffer, " 30%").expect("30% field");
assert_eq!(buffer[(percent_30.0 + 1, percent_30.1)].symbol(), "3");
assert_eq!(buffer[(percent_30.0 + 2, percent_30.1)].symbol(), "0");
assert_eq!(buffer[(percent_30.0 + 3, percent_30.1)].symbol(), "%");
state.context_usage = Some(crate::tui::state::ContextUsageState {
current_tokens: 3000,
max_tokens: 100000,
reasoning_tokens: None,
source: crate::output::ContextUsageSource::ProviderExact,
request_sequence: 2,
});
terminal
.draw(|frame| draw_transcript(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let context_3 = find_ascii_text(buffer, "Context").expect("3% context label");
let percent_3 = find_ascii_text(buffer, " 3%").expect("3% field");
assert_eq!(context_3, context_30);
assert_eq!(percent_3, percent_30);
assert_eq!(buffer[(percent_30.0 + 1, percent_30.1)].symbol(), " ");
assert_eq!(buffer[(percent_30.0 + 2, percent_30.1)].symbol(), "3");
assert_eq!(buffer[(percent_30.0 + 3, percent_30.1)].symbol(), "%");
}
#[test]
fn context_usage_title_joins_with_scroll_label_using_approved_separator() {
let title = [context_usage_title(16274, 272000), "latest".to_string()]
.join(PADDED_INLINE_SEPARATOR);
assert_eq!(title, "🗄️Context 5%/272k • latest");
}
#[test]
fn context_usage_title_rounds_non_even_thousand_limits() {
assert_eq!(context_usage_title(5000, 272600), "🗄️Context 1%/273k");
}
#[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);
assert!(find_text(buffer, "Transcript [Alt-1]").is_none());
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 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!(text_area.width, width);
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, text_area.width),
state.transcript_scroll_overflow(text_area.height, text_area.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 wide_transcript_selection_preserves_card_decoration_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 start = state
.transcript_text_position_for_cell(
1,
4,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
1,
9,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.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();
assert!(find_text(buffer, "┌─ 👤").is_some());
assert!(find_text(buffer, "You").is_some());
let row_start = find_text(buffer, "│ hello").expect("decorated body row");
assert_eq!(buffer[row_start].symbol(), "│");
let right_border = (text_area.x + text_area.width - 2, row_start.1);
assert_eq!(buffer[right_border].symbol(), "│");
assert_ne!(buffer[right_border].bg, MATRIX_GREEN.selection);
assert_ne!(buffer[row_start].bg, MATRIX_GREEN.selection);
assert_ne!(
buffer[(row_start.0 + 1, row_start.1)].bg,
MATRIX_GREEN.selection
);
for offset in 4..9 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
assert_ne!(
buffer[(row_start.0 + 9, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
#[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, 8);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let start = state
.transcript_text_position_for_cell(
1,
2,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
1,
7,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.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, " hello compact").expect("compact body row");
assert_no_text_area_symbol(buffer, text_area, "│");
assert_ne!(buffer[row_start].bg, MATRIX_GREEN.selection);
assert_ne!(
buffer[(row_start.0 + 1, row_start.1)].bg,
MATRIX_GREEN.selection
);
for offset in 2..7 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
assert_ne!(
buffer[(row_start.0 + 7, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
#[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,
crate::tui::render::scroll_view_content_width(text_area),
);
let alpha_row = crate::tui::transcript_cards::visual_lines(
&state,
crate::tui::render::scroll_view_content_width(text_area),
)
.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,
4,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(
alpha_row as u16,
9,
text_area.height,
crate::tui::render::scroll_view_content_width(text_area),
)
.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,
MATRIX_GREEN.selection
);
assert_ne!(
buffer[(row_start.0 + 3, row_start.1)].bg,
MATRIX_GREEN.selection
);
for offset in 4..9 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MATRIX_GREEN.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,
8,
);
let text_area = crate::tui::render::content_text_area_for_pane(TuiPane::Transcript, area);
let wrap_width = crate::tui::render::scroll_view_content_width(text_area);
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, 6, text_area.height, wrap_width)
.expect("selection start");
let end = state
.transcript_text_position_for_cell(alpha_row as u16, 11, 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("wide code row");
for offset in [0, 4, 5] {
assert_ne!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
for offset in 6..11 {
assert_eq!(
buffer[(row_start.0 + offset, row_start.1)].bg,
MATRIX_GREEN.selection
);
}
}
#[test]
fn rendered_selection_slices_zwj_vs16_and_combining_mark_at_grapheme_boundaries() {
let copy_text = "x👨👩👧👦⌨️e\u{0301}z";
let visual_line = crate::tui::transcript_cards::TranscriptVisualLine {
line: Line::from(vec![Span::styled(
copy_text.to_string(),
MATRIX_GREEN.pane(),
)]),
copy_text: copy_text.to_string(),
copy_byte_range: None,
copyable: true,
copy_continuation: false,
visual_copy_column_offset: 0,
activity_button: None,
};
let start = "x".len();
let end = copy_text.len() - "z".len();
let selected = visual_line_with_selection(&visual_line, start, end);
let selected_spans = selected
.spans
.iter()
.filter(|span| span.style == MATRIX_GREEN.selection())
.map(|span| span.content.as_ref())
.collect::<Vec<_>>();
assert_eq!(selected_spans, vec!["👨👩👧👦⌨️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", "👨👩👧👦", "⌨️", "e\u{0301}", "z"].contains(&grapheme)
})
);
}
}
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 assert_no_text_area_symbol(buffer: &ratatui::buffer::Buffer, area: Rect, symbol: &str) {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
assert_ne!(buffer[(x, y)].symbol(), symbol, "symbol at ({x}, {y})");
}
}
}
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 == MATRIX_GREEN.selection())
}
fn find_ascii_text(buffer: &ratatui::buffer::Buffer, needle: &str) -> Option<(u16, u16)> {
let needle = needle.chars().map(|ch| ch.to_string()).collect::<Vec<_>>();
let needle_width = u16::try_from(needle.len()).ok()?;
if needle_width == 0 || needle_width > buffer.area.width {
return None;
}
for y in 0..buffer.area.height {
for x in 0..=buffer.area.width - needle_width {
if needle
.iter()
.enumerate()
.all(|(offset, symbol)| buffer[(x + offset as u16, y)].symbol() == symbol)
{
return Some((x, y));
}
}
}
None
}
#[test]
fn transcript_scrollbar_thumb_tracks_windowed_scroll_position() {
let area = Rect::new(0, 0, 80, 20);
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}"
);
}
}