use super::*;
#[test]
fn reasoning_stream_preview_renders_markdown() {
let mut app = test_app();
app.streams.live_stream_preview = Some(LiveStreamPreview {
kind: StreamKind::Reasoning,
text: "considering **boldly**".into(),
include_leading_blank: false,
});
let lines = app.active_lines(80);
let content_line = lines
.iter()
.find(|line| line_text(line).contains("considering"))
.expect("reasoning preview line");
assert_eq!(line_text(content_line).trim(), "considering boldly");
let bold = content_line
.spans
.iter()
.find(|span| span.content == "boldly")
.expect("bold reasoning span");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
assert!(bold.style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn fullscreen_history_starts_at_bottom() {
let mut app = test_app();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let lines = app.active_lines_for_height(40, 12);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("message 19"), "{rendered}");
assert!(!rendered.contains("message 0"), "{rendered}");
}
#[test]
fn pageup_enters_manual_scroll_and_ctrl_end_returns_to_bottom() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
assert!(app
.handle_history_key(
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
&mut terminal,
)
.unwrap());
assert!(matches!(app.history.scroll(), HistoryScroll::Manual { .. }));
assert!(app.should_show_jump_to_bottom(40, 12, Instant::now()));
assert!(app
.handle_history_key(
KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL),
&mut terminal,
)
.unwrap());
assert_eq!(app.history.scroll(), HistoryScroll::Bottom);
assert!(!app.should_show_jump_to_bottom(40, 12, Instant::now()));
}
#[test]
fn small_scroll_to_rendered_bottom_resumes_bottom_following() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let history_len = app.history_len(40, Instant::now());
let rendered_bottom_start =
history_len.saturating_sub(app.history_height_for_screen(40, 12, Instant::now()));
app.history.set_scroll(HistoryScroll::Manual {
top_line: rendered_bottom_start.saturating_sub(1),
});
app.handle_mouse_event(MouseEventKind::ScrollDown, 0, 0, &mut terminal)
.unwrap();
assert_eq!(app.history.scroll(), HistoryScroll::Bottom);
assert!(!app.should_show_jump_to_bottom(40, 12, Instant::now()));
}
#[test]
fn pagedown_moves_manual_scroll_toward_bottom() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.handle_history_key(
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
let before = match app.history.scroll() {
HistoryScroll::Manual { top_line } => top_line,
HistoryScroll::Bottom => panic!("expected manual scroll"),
};
app.handle_history_key(
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
match app.history.scroll() {
HistoryScroll::Manual { top_line } => assert!(top_line > before),
HistoryScroll::Bottom => {}
}
}
#[test]
fn jump_button_renders_above_composer_only_when_scrolled_up() {
let mut app = test_app();
app.input_ui.set_text("draft".to_string());
app.input_ui.set_cursor(app.input_char_len());
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let bottom_lines = app
.active_lines_for_height(40, 12)
.iter()
.map(line_text)
.collect::<Vec<_>>();
assert!(!bottom_lines
.iter()
.any(|line| line.contains("jump to bottom")));
app.scroll_history_page_up(40, 12, Instant::now());
let scrolled_lines = app
.active_lines_for_height(40, 12)
.iter()
.map(line_text)
.collect::<Vec<_>>();
let button_index = scrolled_lines
.iter()
.position(|line| line.contains("jump to bottom"))
.unwrap();
let input_index = scrolled_lines
.iter()
.position(|line| line.trim_end() == "draft")
.unwrap();
assert!(button_index < input_index, "{scrolled_lines:#?}");
}
#[test]
fn spinner_overlays_last_history_row_without_reducing_layout_height() {
let mut app = test_app();
let area = Rect::new(0, 0, 40, 12);
let idle = app.screen_layout(area, Instant::now());
app.begin_provider_turn_ui();
let loading = app.screen_layout(area, Instant::now());
assert_eq!(idle.history, loading.history);
assert_eq!(idle.activity, None);
assert_eq!(idle.history_content, idle.history);
assert_eq!(idle.activity_gap, None);
let activity = loading.activity.unwrap();
assert_eq!(activity.y, loading.history.bottom().saturating_sub(1));
assert!(activity.width < loading.history.width);
assert_eq!(
loading.history_content.height as usize,
loading
.history
.height
.saturating_sub(super::super::activity::bottom_follow_activity_inset(true, true) as u16)
as usize
);
assert_eq!(
loading.activity_gap.unwrap(),
Rect::new(
loading.history.x,
activity
.y
.saturating_sub(super::super::activity::ACTIVITY_CONTENT_GAP_ROWS as u16),
loading.history.width,
super::super::activity::ACTIVITY_CONTENT_GAP_ROWS as u16,
)
);
}
#[test]
fn spinner_offsets_bottom_following_but_not_manual_scroll() {
let mut app = test_app();
app.begin_provider_turn_ui();
let area = Rect::new(0, 0, 40, 12);
let bottom = app.screen_layout(area, Instant::now());
let inset = super::super::activity::bottom_follow_activity_inset(true, true);
assert_eq!(
bottom.history_content.height as usize,
(bottom.history.height as usize).saturating_sub(inset)
);
assert!(bottom.activity_gap.is_some());
assert_eq!(
app.visible_history_window(20, bottom.history_content.height as usize),
(
20usize.saturating_sub(bottom.history_content.height as usize),
bottom.history_content.height as usize,
)
);
app.history
.set_scroll(HistoryScroll::Manual { top_line: 3 });
let manual = app.screen_layout(area, Instant::now());
assert_eq!(manual.history_content, manual.history);
assert_eq!(manual.activity_gap, None);
assert_eq!(
app.visible_history_window(20, manual.history_content.height as usize),
(3, manual.history_content.height as usize)
);
}
#[test]
fn spinner_and_jump_button_share_activity_row_with_jump_right_aligned() {
let mut app = test_app();
app.begin_provider_turn_ui();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.scroll_history_page_up(40, 12, Instant::now());
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let activity = layout.activity.unwrap();
let button = layout.jump_to_bottom.unwrap();
assert_eq!(activity.y, button.y);
assert_eq!(activity.x, layout.history.x);
assert!(activity.right() < button.x);
assert_eq!(button.right(), layout.history.right());
}
#[test]
fn bottom_following_renders_last_message_above_spinner_overlay() {
let mut app = test_app();
app.begin_provider_turn_ui();
for index in 0..20 {
app.push_transcript_entry(Entry::Assistant(format!("message {index}")));
}
let width = 40;
let height = 12;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let layout = app.screen_layout(Rect::new(0, 0, width, height), Instant::now());
let activity = layout.activity.unwrap();
let gap = layout.activity_gap.expect("bottom-follow activity gap");
let rows = (0..height)
.map(|row| buffer_row_text(terminal.backend().buffer(), row))
.collect::<Vec<_>>();
assert!(
rows[..layout.history_content.bottom() as usize]
.iter()
.any(|row| row.contains("message 19")),
"{rows:#?}"
);
assert!(
!rows[activity.y as usize].contains("message 19"),
"{rows:#?}"
);
assert!(
rows[gap.y as usize].trim().is_empty(),
"expected a blank spacer row above the spinner, got {rows:#?}"
);
assert_eq!(gap.bottom(), activity.y);
assert_eq!(layout.history_content.bottom(), gap.y);
}
#[test]
fn jump_button_preserves_uncovered_content_on_last_scrolled_row() {
let mut app = test_app();
let width = 40;
let height = 12;
let now = Instant::now();
for index in 0..30 {
app.push_transcript_entry(Entry::Assistant(format!(
"message {index:02} remains visible across this row"
)));
}
let history_len = app.history_len(width as usize, now);
let layout = app.screen_layout(Rect::new(0, 0, width, height), now);
let history_height = layout.history.height as usize;
let top_line = (0..history_len.saturating_sub(history_height))
.find(|top_line| {
app.visible_history_lines(width as usize, now, *top_line, history_height)
.last()
.is_some_and(|line| !line_text(line).trim().is_empty())
})
.unwrap();
app.history.set_scroll(HistoryScroll::Manual { top_line });
let layout = app.screen_layout(Rect::new(0, 0, width, height), now);
let button = layout.jump_to_bottom.unwrap();
let expected = app
.visible_history_lines(width as usize, now, top_line, history_height)
.last()
.map(line_text)
.unwrap();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let rendered = buffer_row_text(terminal.backend().buffer(), button.y);
let preserved_width = button.x as usize;
assert!(
!expected[..preserved_width].trim().is_empty(),
"{expected:?}"
);
assert_eq!(&rendered[..preserved_width], &expected[..preserved_width]);
assert!(
rendered.ends_with("↓ jump to bottom ctrl+end"),
"{rendered:?}"
);
}
#[test]
fn activity_rail_has_a_solid_full_width_background() {
let mut app = test_app();
let width = 40;
let height = 12;
app.begin_provider_turn_ui();
app.push_transcript_entry(test_tool_entry(
true,
&[
"tool line 0",
"tool line 1",
"tool line 2",
"tool line 3",
"tool line 4",
"tool line 5",
"tool line 6",
"tool line 7",
"tool line 8",
"tool line 9",
],
));
let layout = app.screen_layout(Rect::new(0, 0, width, height), Instant::now());
let rail = layout.activity_rail.unwrap();
let activity = layout.activity.unwrap();
let scrollbar = layout.history_scrollbar.unwrap();
app.reveal_history_scrollbar(Instant::now());
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
assert_eq!(rail, Rect::new(0, rail.y, width, 1));
assert_eq!(scrollbar.rect.bottom(), layout.history_content.bottom());
assert!(scrollbar.rect.bottom() <= rail.y);
let buffer = terminal.backend().buffer();
let rail_background = Theme::activity_rail().bg.unwrap();
assert_ne!(
buffer[(rail.x, rail.y.saturating_sub(1))].bg,
rail_background
);
for column in rail.x..rail.right() {
assert_eq!(buffer[(column, rail.y)].bg, rail_background);
}
for column in activity.right()..rail.right() {
assert_eq!(buffer[(column, rail.y)].symbol(), " ");
}
for row in rail.bottom()..height {
for column in rail.x..rail.right() {
assert_ne!(buffer[(column, row)].bg, rail_background);
}
}
assert_eq!(
buffer[(scrollbar.rect.x, scrollbar.rect.bottom().saturating_sub(1))].symbol(),
"â–ˆ"
);
}
#[test]
fn activity_rail_clears_inherited_text_modifiers() {
let mut app = test_app();
let width = 40;
let height = 12;
for index in 0..20 {
app.push_transcript_entry(Entry::Notice(format!("italic status {index}")));
}
app.begin_provider_turn_ui();
let layout = app.screen_layout(Rect::new(0, 0, width, height), Instant::now());
let rail = layout.activity_rail.unwrap();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let buffer = terminal.backend().buffer();
let content_bottom = layout.history_content.bottom().saturating_sub(1);
assert!(buffer[(layout.history_content.x, content_bottom)]
.modifier
.contains(Modifier::ITALIC));
for column in rail.x..rail.right() {
assert!(!buffer[(column, rail.y)].modifier.contains(Modifier::ITALIC));
}
if let Some(gap) = layout.activity_gap {
for column in gap.x..gap.right() {
assert!(!buffer[(column, gap.y)].modifier.contains(Modifier::ITALIC));
assert_eq!(buffer[(column, gap.y)].symbol(), " ");
}
}
}
#[test]
fn jump_button_uses_activity_rail_background() {
let mut app = test_app();
let width = 40;
let height = 12;
app.push_transcript_entry(test_tool_entry(
true,
&[
"tool line 0",
"tool line 1",
"tool line 2",
"tool line 3",
"tool line 4",
"tool line 5",
"tool line 6",
"tool line 7",
"tool line 8",
"tool line 9",
],
));
for index in 0..20 {
app.push_transcript_entry(Entry::Assistant(format!("later message {index}")));
}
app.history
.set_scroll(HistoryScroll::Manual { top_line: 0 });
let layout = app.screen_layout(Rect::new(0, 0, width, height), Instant::now());
let button = layout.jump_to_bottom.unwrap();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let buffer = terminal.backend().buffer();
let rail_background = Theme::activity_rail().bg.unwrap();
assert_ne!(
buffer[(button.x.saturating_sub(1), button.y)].bg,
rail_background
);
for column in button.x..button.right() {
assert_eq!(buffer[(column, button.y)].bg, rail_background);
}
}
#[test]
fn compact_jump_button_renders_on_narrow_terminals() {
let app = test_app();
assert!(line_text(&app.jump_to_bottom_line(16)).contains("ctrl+end"));
assert!(line_text(&app.jump_to_bottom_line(40)).contains("ctrl+end"));
}
#[test]
fn mouse_wheel_scrolls_history_and_clicking_jump_button_returns_to_bottom() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.handle_mouse_event(MouseEventKind::ScrollUp, 0, 0, &mut terminal)
.unwrap();
assert!(matches!(app.history.scroll(), HistoryScroll::Manual { .. }));
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let button = layout.jump_to_bottom.unwrap();
assert!(button.x > 0);
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
button.x,
button.y,
&mut terminal,
)
.unwrap();
assert_eq!(app.history.scroll(), HistoryScroll::Bottom);
}
#[test]
fn scrollbar_hides_until_scroll_or_hover() {
let mut app = test_app();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let height = 12;
let mut terminal = Terminal::new(TestBackend::new(40, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let rows = (0..height)
.map(|row| buffer_row_text(terminal.backend().buffer(), row))
.collect::<Vec<_>>();
assert!(!rows.iter().any(|row| row.ends_with('â–ˆ')), "{rows:#?}");
}
#[test]
fn scrollbar_renders_briefly_after_mouse_wheel_scroll() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.handle_mouse_event(MouseEventKind::ScrollUp, 0, 0, &mut terminal)
.unwrap();
assert!(app.should_render_history_scrollbar(Instant::now()));
}
#[test]
fn scrollbar_renders_while_hovered() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let scrollbar = layout.history_scrollbar.unwrap();
app.handle_mouse_event(
MouseEventKind::Moved,
scrollbar.rect.x,
scrollbar.rect.y,
&mut terminal,
)
.unwrap();
assert!(app.should_render_history_scrollbar(Instant::now()));
}
#[test]
fn dragging_scrollbar_updates_history_scroll() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..30 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.scroll_history_page_up(40, 12, Instant::now());
app.scroll_history_page_up(40, 12, Instant::now());
app.scroll_history_page_up(40, 12, Instant::now());
app.reveal_history_scrollbar(Instant::now());
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let scrollbar = layout.history_scrollbar.unwrap();
let thumb_row = (scrollbar.rect.y..scrollbar.rect.y.saturating_add(scrollbar.rect.height))
.find(|row| {
matches!(
scrollbar.begin_drag(*row),
HistoryScrollbarDrag::Thumb { .. }
)
})
.unwrap();
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
scrollbar.rect.x,
thumb_row,
&mut terminal,
)
.unwrap();
assert!(matches!(
app.history.scrollbar_drag(),
Some(HistoryScrollbarDrag::Thumb { .. })
));
let before = match app.history.scroll() {
HistoryScroll::Manual { top_line } => top_line,
HistoryScroll::Bottom => panic!("expected manual scroll"),
};
let drag_row = scrollbar.rect.y;
app.handle_mouse_event(
MouseEventKind::Drag(MouseButton::Left),
scrollbar.rect.x,
drag_row,
&mut terminal,
)
.unwrap();
let after = match app.history.scroll() {
HistoryScroll::Manual { top_line } => top_line,
HistoryScroll::Bottom => usize::MAX,
};
assert!(after < before, "before={before} after={after}");
app.handle_mouse_event(
MouseEventKind::Up(MouseButton::Left),
scrollbar.rect.x,
drag_row,
&mut terminal,
)
.unwrap();
assert_eq!(app.history.scrollbar_drag(), None);
}
#[test]
fn clicking_scrollbar_track_jumps_history_scroll() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..30 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let scrollbar = layout.history_scrollbar.unwrap();
app.reveal_history_scrollbar(Instant::now());
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
scrollbar.rect.x,
scrollbar.rect.y.saturating_add(scrollbar.rect.height - 1),
&mut terminal,
)
.unwrap();
assert_eq!(app.history.scroll(), HistoryScroll::Bottom);
}
#[test]
fn clicking_hidden_scrollbar_does_not_scroll_history() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
for index in 0..30 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.scroll_history_page_up(40, 12, Instant::now());
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let scrollbar = layout.history_scrollbar.unwrap();
let before = app.history.scroll();
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
scrollbar.rect.x,
scrollbar
.rect
.y
.saturating_add(scrollbar.rect.height.saturating_sub(2)),
&mut terminal,
)
.unwrap();
assert_eq!(app.history.scroll(), before);
assert_eq!(app.history.scrollbar_drag(), None);
}
#[test]
fn clamping_bottom_scroll_preserves_scrollbar_hover() {
let mut app = test_app();
for index in 0..30 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let layout = app.screen_layout(Rect::new(0, 0, 40, 12), Instant::now());
let scrollbar = layout.history_scrollbar.unwrap();
app.update_history_scrollbar_hover(
layout.history_scrollbar,
scrollbar.rect.x,
scrollbar.rect.y,
);
app.clamp_history_scroll(40, 12, Instant::now());
assert!(app.history.scrollbar_hovered());
assert!(app.should_render_history_scrollbar(Instant::now()));
}
#[test]
fn manual_scroll_preserves_top_line_when_new_output_arrives() {
let mut app = test_app();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
app.scroll_history_page_up(40, 12, Instant::now());
let before_len = app.history_len(40, Instant::now());
let before_start = app.visible_history_start(before_len, 6);
app.push_transcript_entry(Entry::Assistant("new output".into()));
let after_len = app.history_len(40, Instant::now());
let after_start = app.visible_history_start(after_len, 6);
assert_eq!(after_start, before_start);
}
#[test]
fn bottom_scroll_follows_new_output() {
let mut app = test_app();
for index in 0..20 {
app.push_transcript_entry(Entry::User(format!("message {index}")));
}
let before_len = app.history_len(40, Instant::now());
let before_start = app.visible_history_start(before_len, 6);
app.push_transcript_entry(Entry::Assistant("new output".into()));
let after_len = app.history_len(40, Instant::now());
let after_start = app.visible_history_start(after_len, 6);
assert!(after_start > before_start);
}
#[test]
fn repeated_statusline_frames_render_once() {
let mut app = test_app();
for _ in 0..10_000 {
app.statusline_lines(120);
}
assert_eq!(app.statusline.render_count(), 1);
}
#[test]
fn hidden_reasoning_shows_thinking_placeholder() {
let mut app = test_app();
app.info.runtime.show_reasoning_output = false;
app.record_agent_event(ViewModelEvent::StepStarted(1));
let thinking = app
.history_live_lines(60, Instant::now())
.into_iter()
.find(|line| line_text(line).contains("Thinking..."))
.unwrap();
assert_eq!(thinking.spans[1].style, StreamKind::Reasoning.style());
app.reset_streams();
assert!(!app.turn.reasoning_phase().hidden_placeholder());
}
#[test]
fn hidden_reasoning_replaces_thinking_with_thought_summary() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.info.runtime.show_reasoning_output = false;
app.handle_agent_event(ViewModelEvent::StepStarted(1), &mut terminal)
.unwrap();
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("secret".into()),
&mut terminal,
)
.unwrap();
assert!(app.turn.reasoning_phase().hidden_placeholder());
assert!(app
.history_live_lines(60, Instant::now())
.iter()
.any(|line| line_text(line).contains("Thinking...")));
app.handle_agent_event(ViewModelEvent::OutputDelta("answer".into()), &mut terminal)
.unwrap();
app.finish_streams();
assert!(!app.turn.reasoning_phase().hidden_placeholder());
assert!(app
.history_live_lines(60, Instant::now())
.iter()
.all(|line| !line_text(line).contains("Thinking...")));
assert!(
matches!(
app.history.entries(),
[
Entry::Reasoning(ReasoningEntry {
text,
thought_for: Some(_),
}),
Entry::Assistant(answer)
] if text.is_empty() && answer == "answer"
),
"{:?}",
app.history.entries()
);
}
#[test]
fn shown_reasoning_appends_thought_summary_after_reasoning() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.info.runtime.show_reasoning_output = true;
app.handle_agent_event(ViewModelEvent::StepStarted(1), &mut terminal)
.unwrap();
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("because reasons".into()),
&mut terminal,
)
.unwrap();
app.handle_agent_event(ViewModelEvent::OutputDelta("answer".into()), &mut terminal)
.unwrap();
app.finish_streams();
assert!(
matches!(
app.history.entries(),
[
Entry::Reasoning(ReasoningEntry {
text: reasoning,
thought_for: Some(_),
}),
Entry::Assistant(answer)
] if reasoning == "because reasons" && answer == "answer"
),
"{:?}",
app.history.entries()
);
}
#[test]
fn thinking_without_reasoning_deltas_skips_thought_summary() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.info.runtime.show_reasoning_output = false;
app.handle_agent_event(ViewModelEvent::StepStarted(1), &mut terminal)
.unwrap();
app.handle_agent_event(ViewModelEvent::OutputDelta("answer".into()), &mut terminal)
.unwrap();
app.finish_streams();
assert!(
matches!(app.history.entries(), [Entry::Assistant(answer)] if answer == "answer"),
"{:?}",
app.history.entries()
);
}
#[test]
fn toggling_reasoning_output_off_mid_turn_hides_later_deltas() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.info.runtime.show_reasoning_output = true;
app.begin_provider_turn_ui();
app.handle_agent_event(ViewModelEvent::StepStarted(1), &mut terminal)
.unwrap();
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("visible first\n".into()),
&mut terminal,
)
.unwrap();
assert!(
matches!(
app.history.entries(),
[Entry::Reasoning(ReasoningEntry { text, thought_for: None })]
if text.contains("visible first")
),
"{:?}",
app.history.entries()
);
app.info.runtime.show_reasoning_output = false;
app.apply_reasoning_output_visibility();
assert!(app.turn.reasoning_phase().hidden_placeholder());
assert!(app
.history_live_lines(60, Instant::now())
.iter()
.any(|line| line_text(line).contains("Thinking...")));
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("hidden later".into()),
&mut terminal,
)
.unwrap();
app.handle_agent_event(ViewModelEvent::OutputDelta("answer".into()), &mut terminal)
.unwrap();
app.finish_streams();
assert!(
matches!(
app.history.entries(),
[
Entry::Reasoning(ReasoningEntry {
text: reasoning,
thought_for: Some(_),
}),
Entry::Assistant(answer)
] if reasoning.contains("visible first")
&& !reasoning.contains("hidden later")
&& answer == "answer"
),
"{:?}",
app.history.entries()
);
}
#[test]
fn toggling_reasoning_output_on_mid_turn_shows_later_deltas() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.info.runtime.show_reasoning_output = false;
app.begin_provider_turn_ui();
app.handle_agent_event(ViewModelEvent::StepStarted(1), &mut terminal)
.unwrap();
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("hidden first".into()),
&mut terminal,
)
.unwrap();
assert!(app.turn.reasoning_phase().hidden_placeholder());
app.info.runtime.show_reasoning_output = true;
app.apply_reasoning_output_visibility();
assert!(!app.turn.reasoning_phase().hidden_placeholder());
assert!(app
.history_live_lines(60, Instant::now())
.iter()
.all(|line| !line_text(line).contains("Thinking...")));
app.handle_agent_event(
ViewModelEvent::ReasoningDelta("visible later".into()),
&mut terminal,
)
.unwrap();
app.handle_agent_event(ViewModelEvent::OutputDelta("answer".into()), &mut terminal)
.unwrap();
app.finish_streams();
assert!(
matches!(
app.history.entries(),
[
Entry::Reasoning(ReasoningEntry {
text: reasoning,
thought_for: Some(_),
}),
Entry::Assistant(answer)
] if reasoning == "visible later" && answer == "answer"
),
"{:?}",
app.history.entries()
);
}
#[test]
fn started_tool_display_ignores_late_argument_previews() {
let mut app = test_app();
let call_id = rho_sdk::ToolCallId::from_string("call-layout").unwrap();
app.record_agent_event(ViewModelEvent::ToolCallUpdated {
index: 0,
call_id: Some(call_id.clone()),
display_lines: vec!["edit_file".into()],
});
app.record_agent_event(ViewModelEvent::ToolStarted {
call_id,
display_lines: vec!["edit_file src/main.rs".into()],
});
app.record_agent_event(ViewModelEvent::ToolCallUpdated {
index: 0,
call_id: None,
display_lines: vec!["edit_file".into()],
});
assert_eq!(
app.turn
.tool_calls()
.running
.values()
.next()
.map(|tool| tool.display_lines.as_slice()),
Some(["edit_file src/main.rs".to_string()].as_slice())
);
}
#[test]
fn web_search_api_key_editor_preserves_parent_picker() {
let config_dir = tempfile::tempdir().unwrap();
let mut app = test_app();
app.info.services.config_repository =
ConfigRepository::new(Some(config_dir.path().join("config.toml")));
let config = app.info.services.config_repository.load().unwrap();
let mut root = config_picker::config_picker(&app.info.runtime, &config);
App::restore_picker_position(
&mut root,
config_picker::TOOLS_CATEGORY_VALUE,
String::new(),
);
let mut parent = config_picker::category_picker(
config_picker::TOOLS_CATEGORY_VALUE,
&app.info.runtime,
&config,
)
.unwrap()
.with_parent(root);
App::restore_picker_position(&mut parent, config_picker::WEB_SEARCH_VALUE, "web".into());
app.input_ui.set_composer(ComposerMode::Picker(parent));
let child = config_picker::web_search_config_picker(&config, app.credential_store.as_ref());
app.open_child_picker(child);
app.open_web_search_api_key_editor(ConfigTextKey::Exa)
.unwrap();
app.handle_config_text_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
app.handle_picker_escape( false).unwrap();
let ComposerMode::Picker(picker) = app.input_ui.composer() else {
panic!("expected parent picker after API-key editor escape");
};
assert_eq!(
picker.selected_item().unwrap().value,
config_picker::WEB_SEARCH_VALUE
);
assert_eq!(picker.filter, "web");
}
#[test]
fn config_toggle_keeps_its_category_open() {
let config_dir = tempfile::tempdir().unwrap();
let mut app = test_app();
app.info.services.config_repository =
ConfigRepository::new(Some(config_dir.path().join("config.toml")));
let original = app
.info
.services
.config_repository
.load()
.unwrap()
.check_for_updates;
app.open_main_config_picker_selected(config_picker::CHECK_FOR_UPDATES_VALUE)
.unwrap();
app.toggle_check_for_updates().unwrap();
let ComposerMode::Picker(picker) = app.input_ui.composer() else {
panic!("expected config category picker");
};
assert_eq!(picker.title, "Config / Updates");
assert_eq!(
picker.selected_item().unwrap().value,
config_picker::CHECK_FOR_UPDATES_VALUE
);
assert_eq!(
picker.selected_item().unwrap().badge.as_ref().unwrap().text,
if original { "off" } else { "on" }
);
assert!(picker.has_parent());
}