use super::*;
struct FakeClipboard {
result: anyhow::Result<()>,
calls: usize,
}
impl clipboard::ClipboardWriter for FakeClipboard {
fn copy(&mut self, _text: &str) -> anyhow::Result<()> {
self.calls += 1;
self.result
.as_ref()
.map(|_| ())
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
}
fn mouse(
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
) -> crossterm::event::MouseEvent {
crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
#[test]
fn system_prompt_and_rewind_wheel_include_gutter_but_not_modal_edges() {
use crossterm::event::MouseEventKind::{Down, Drag, ScrollDown, ScrollUp};
let terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
for modal in [state::ModalKind::SystemPrompt, state::ModalKind::Rewind] {
let mut state = state::MissionControlState::default();
let (text, batch_target, scroll_target) = match modal {
state::ModalKind::SystemPrompt => {
state.open_system_prompt_modal("system prompt".to_string());
(
render::system_prompt_text_area(terminal_area).unwrap(),
MouseScrollTarget::SystemPrompt,
input::ScrollTarget::SystemPrompt,
)
}
state::ModalKind::Rewind => {
state.open_rewind_modal("rewind".to_string(), None, false);
(
render::rewind_modal_text_area(terminal_area).unwrap(),
MouseScrollTarget::RewindModal,
input::ScrollTarget::Rewind,
)
}
_ => unreachable!(),
};
for kind in [ScrollDown, ScrollUp] {
for column in [text.x, text.right() - 1] {
let event = mouse(kind, column, text.y);
assert_eq!(
mouse_scroll_batch(event, terminal_area, &state),
Some(MouseScrollBatch {
target: batch_target,
kind,
}),
"{modal:?} at column {column}"
);
assert_eq!(
classify_mouse_event(event, terminal_area, &state, 3),
Some(input::DisplayCommand::Scroll(input::ScrollCommand {
target: scroll_target,
down: kind == ScrollDown,
rows: 3,
visible_rows: text.height,
wrap_width: render::scroll_view_content_width(text),
}))
);
}
for (column, row) in [
(text.x - 1, text.y),
(text.right(), text.y),
(text.right() - 1, text.y - 1),
(text.right() - 1, text.bottom()),
] {
let event = mouse(kind, column, row);
assert_eq!(mouse_scroll_batch(event, terminal_area, &state), None);
assert_eq!(classify_mouse_event(event, terminal_area, &state, 3), None);
}
}
for kind in [
Down(crossterm::event::MouseButton::Left),
Drag(crossterm::event::MouseButton::Left),
] {
let event = mouse(kind, text.right() - 1, text.y);
assert_eq!(mouse_scroll_batch(event, terminal_area, &state), None);
assert_eq!(
classify_mouse_event(event, terminal_area, &state, 3),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
}
}
}
#[test]
fn usage_wheel_scrolls_content_and_gutter_without_reaching_underlying_panes() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
state.open_usage_modal_loading();
state.set_usage_modal_result(crate::tui::usage::UsageLoadResult::Loaded(
crate::tui::usage::UsageReport {
generated_at: None,
help: Vec::new(),
warning: None,
providers: (0..40)
.map(|row| crate::tui::usage::UsageProviderRow {
provider: format!("provider-{row}"),
label: format!("Provider {row}"),
source: "source".to_string(),
plan: None,
status: "fresh".to_string(),
windows: Vec::new(),
})
.collect(),
},
));
let text = render::usage_modal_text_area(area).unwrap();
assert_eq!(state.top_modal(), Some(state::ModalKind::Usage));
assert!(state.usage_scroll_overflow(text.height, render::scroll_view_content_width(text)) > 3);
for column in [text.x, text.right() - 1] {
assert!(matches!(
classify_mouse_event(
mouse(crossterm::event::MouseEventKind::ScrollDown, column, text.y),
area,
&state,
3
),
Some(input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Usage,
..
}))
));
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollDown, column, text.y),
area,
&mut state,
3,
));
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 3);
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollUp, column, text.y),
area,
&mut state,
3,
));
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 0);
}
assert!(!handle_mouse_event(
mouse(crossterm::event::MouseEventKind::ScrollDown, 1, 1),
area,
&mut state,
));
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 0);
}
#[test]
fn main_pane_wheel_gutters_match_content_but_do_not_start_selection() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let layout = render::layout_for(area, &state);
for pane in [
render::TuiPane::Transcript,
render::TuiPane::ActivityTree,
render::TuiPane::Detail,
] {
let text = render::content_text_area_for_pane(pane, layout.pane_area(pane).unwrap());
for kind in [
crossterm::event::MouseEventKind::ScrollUp,
crossterm::event::MouseEventKind::ScrollDown,
] {
let content = mouse_scroll_batch(mouse(kind, text.x, text.y), area, &state).unwrap();
let gutter =
mouse_scroll_batch(mouse(kind, text.right() - 1, text.y), area, &state).unwrap();
assert!(content.coalesces_with(gutter), "{pane:?}");
let content_command =
classify_mouse_event(mouse(kind, text.x, text.y), area, &state, 3).unwrap();
let gutter_command =
classify_mouse_event(mouse(kind, text.right() - 1, text.y), area, &state, 3)
.unwrap();
assert_eq!(content_command, gutter_command);
assert!(matches!(gutter_command, input::DisplayCommand::Scroll(_)));
}
assert!(matches!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.right() - 1,
text.y
),
area,
&state,
3,
),
Some(input::DisplayCommand::Mouse(
input::MouseCommand::ClearSelection
))
));
}
let prompt = render::content_text_area_for_pane(
render::TuiPane::Prompt,
layout.pane_area(render::TuiPane::Prompt).unwrap(),
);
for column in [prompt.x, prompt.right() - 1] {
assert!(
mouse_scroll_batch(
mouse(
crossterm::event::MouseEventKind::ScrollDown,
column,
prompt.y
),
area,
&state
)
.is_none()
);
}
}
#[test]
fn mouse_scroll_batch_classifies_same_target_batches_and_excludes_clicks() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let first = mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4);
let second = mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 5);
let click = mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
1,
4,
);
let other_direction = mouse(crossterm::event::MouseEventKind::ScrollDown, 1, 4);
let detail = mouse(crossterm::event::MouseEventKind::ScrollUp, 61, 18);
let first_batch = mouse_scroll_batch(first, area, &state).expect("scroll batch");
assert!(
first_batch.coalesces_with(mouse_scroll_batch(second, area, &state).expect("same target"))
);
assert!(!first_batch.coalesces_with(
mouse_scroll_batch(other_direction, area, &state).expect("direction differs")
));
assert!(
!first_batch
.coalesces_with(mouse_scroll_batch(detail, area, &state).expect("target differs"))
);
assert_eq!(mouse_scroll_batch(click, area, &state), None);
}
#[test]
fn invalid_mouse_hits_clear_selection_through_classifier_and_reducer() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
prompt_editor: crate::tui::state::PromptEditor::new("abcdef", 6),
..Default::default()
};
let prompt_area = render::layout_for(area, &state)
.pane_area(render::TuiPane::Prompt)
.expect("prompt pane");
let coordinates = [
(prompt_area.x, prompt_area.y),
(
area.x.saturating_add(area.width),
area.y.saturating_add(area.height),
),
];
for (column, row) in coordinates {
state.start_prompt_selection_at(0);
state.update_prompt_selection_at(3);
assert!(state.prompt_selection_text().is_some());
let command = classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&state,
1,
)
.expect("invalid hit command");
assert!(matches!(
command,
input::DisplayCommand::Mouse(input::MouseCommand::ClearSelection)
));
let (changed, copy) =
crate::tui::controller::apply_display_command_for_test(command, &mut state, &[]);
assert!(changed);
assert_eq!(copy, None);
assert!(state.prompt_selection_text().is_none());
}
}
#[test]
fn batched_mouse_scroll_applies_row_delta_and_clamps_detail() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..40)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
let layout = render::layout_for(area, &state);
let detail = layout.pane_area(render::TuiPane::Detail).unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Detail, detail);
let rows = state::MOUSE_WHEEL_SCROLL_ROWS * 3;
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y,),
area,
&mut state,
rows,
));
assert_eq!(state.detail_offset(), usize::from(rows));
let body_rows = state.detail_body_viewport_rows(text.height, text.width.max(1));
let full_pane_overflow = state.detail_overflow(text.height, text.width.max(1));
let overflow = state.detail_overflow(body_rows, text.width.max(1));
assert!(overflow > full_pane_overflow);
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y,),
area,
&mut state,
u16::MAX,
));
assert_eq!(state.detail_offset(), overflow);
}
#[test]
fn system_prompt_modal_mouse_wheel_scrolls_content_and_blocks_fallthrough() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
for line in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("transcript line {line}"),
});
}
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..20)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
state.set_prompt_text(&"x".repeat(1000), 0);
state.open_system_prompt_modal(
(0..40)
.map(|line| format!("system prompt line {line}"))
.collect::<Vec<_>>()
.join("\n"),
);
let modal_content = render::system_prompt_text_area(area).unwrap();
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: modal_content.x + modal_content.width / 2,
row: modal_content.y + modal_content.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.scroll_offset(&state.scroll_views.system_prompt),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: modal_content.x + modal_content.width / 2,
row: modal_content.y + modal_content.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.scroll_offset(&state.scroll_views.system_prompt), 0);
let before = (
state.transcript_scroll_offset(),
state.activity_scroll_offset(),
state.detail_offset(),
state.help_offset(),
state.scroll_offset(&state.scroll_views.system_prompt),
);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
before,
(
state.transcript_scroll_offset(),
state.activity_scroll_offset(),
state.detail_offset(),
state.help_offset(),
state.scroll_offset(&state.scroll_views.system_prompt),
)
);
}
#[test]
fn mouse_wheel_scrolls_hovered_scrollable_pane() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
for line in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("line {line}"),
});
}
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..20)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.detail_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.detail_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
for index in 0..20 {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new(format!("tree-{index}")),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(format!("tree item {index}")),
});
}
state.selected = 0;
let selected_before = state.selected_activity_id();
let detail_before = state.selected_activity_text();
state.set_scroll_offset(&state.scroll_views.activity_tree, 0);
state.set_scroll_offset(&state.scroll_views.detail, 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 61,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.activity_scroll_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.selected, 0);
assert_eq!(state.selected_activity_id(), selected_before);
assert_eq!(state.selected_activity_text(), detail_before);
assert_eq!(state.detail_offset(), 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.selected, 0);
assert_eq!(state.detail_offset(), 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.transcript_scroll_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.transcript_scroll_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
state.set_prompt_text(&"0123456789".repeat(100), 0);
let layout = render::layout_for(area, &state);
let prompt = layout.pane_area(render::TuiPane::Prompt).unwrap();
let prompt_before = (
state.prompt_plain_text(),
state.prompt_cursor(),
state.prompt_selection_text(),
state.detail_offset(),
);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: prompt.x + 1,
row: prompt.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: prompt.x + 1,
row: prompt.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
prompt_before,
(
state.prompt_plain_text(),
state.prompt_cursor(),
state.prompt_selection_text(),
state.detail_offset(),
)
);
state.show_help = true;
let help = render::layout_for(area, &state)
.pane_area(render::TuiPane::Help)
.unwrap();
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: help.x + help.width / 2,
row: help.y + help.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.help_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: help.x + help.width / 2,
row: help.y + help.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.help_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 99,
row: 29,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
}
#[test]
fn prompt_wheel_is_a_no_op_and_does_not_replace_the_real_hit_map() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("hello world", 0),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.prompt_editor.start_selection_at(1);
state.prompt_editor.update_selection_at(5);
let layout = render::layout_for(area, &state);
let prompt = layout.pane_area(render::TuiPane::Prompt).unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Prompt, prompt);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
.unwrap();
terminal.draw(|frame| render::draw(frame, &state)).unwrap();
let before = (
state.prompt_plain_text(),
state.prompt_cursor(),
state.prompt_selection_text(),
);
assert_eq!(
mouse_scroll_batch(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y),
area,
&state,
),
None
);
assert!(!handle_mouse_event(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y),
area,
&mut state,
));
assert_eq!(
(
state.prompt_plain_text(),
state.prompt_cursor(),
state.prompt_selection_text(),
),
before
);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left,),
text.x + 1,
text.y
),
area,
&mut state,
));
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left,),
text.x + 5,
text.y
),
area,
&mut state,
));
assert_eq!(state.prompt_selection_text().as_deref(), Some("ello"));
}
#[test]
fn hidden_activity_column_routes_former_right_side_to_transcript() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
for line in 0..40 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("line {line}"),
});
}
state.toggle_activity_column();
let layout = render::layout_for(area, &state);
assert_eq!(layout.pane_at(90, 4), Some(render::TuiPane::Transcript));
assert_eq!(layout.pane_area(render::TuiPane::ActivityTree), None);
assert_eq!(layout.pane_area(render::TuiPane::Detail), None);
assert_eq!(
mouse_scroll_batch(
mouse(crossterm::event::MouseEventKind::ScrollUp, 90, 4,),
area,
&state
)
.map(|batch| batch.target),
Some(MouseScrollTarget::Transcript)
);
assert!(handle_mouse_event(
mouse(crossterm::event::MouseEventKind::ScrollUp, 90, 4,),
area,
&mut state,
));
assert_eq!(
state.transcript_scroll_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
}
#[test]
fn mouse_activity_tree_click_selects_and_second_click_toggles() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
let parent = crate::output::ActivityId::new("parent");
let child = crate::output::ActivityId::new("child");
let sibling = crate::output::ActivityId::new("sibling");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: parent.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("parent item"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: child,
parent_id: Some(parent.clone()),
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("child item"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: sibling.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("sibling item"),
});
let tree = render::layout_for(area, &state)
.pane_area(render::TuiPane::ActivityTree)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::ActivityTree, tree);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y + 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert!(state.is_activity_tree_focused());
assert_eq!(state.selected_activity_id(), Some(sibling.clone()));
assert!(state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(!state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(state.expanded.contains(&parent));
}
#[test]
fn mouse_detail_metadata_heading_toggles_but_other_detail_rows_do_not() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
let mut metadata = crate::output::ActivityMetadata::new("metadata click");
metadata.fields = vec![("value".to_string(), "visible expanded value".to_string())];
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("metadata-click"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Success,
metadata,
});
let detail = render::layout_for(area, &state)
.pane_area(render::TuiPane::Detail)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Detail, detail);
let wrap_width = render::scroll_view_content_width(text).max(1);
let header_row = state
.detail_metadata_header_viewport_rows(text.height, wrap_width)
.unwrap()
.start;
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x,
text.y + header_row,
),
area,
&mut state,
));
assert!(state.is_activity_detail_focused());
assert!(state.detail_metadata_expanded());
assert!(
state
.visible_activity_body_lines(0, usize::MAX, wrap_width)
.iter()
.any(|line| line.plain_text().contains("visible expanded value"))
);
assert!(!handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x,
text.y,
),
area,
&mut state,
));
assert!(state.detail_metadata_expanded());
let header_row = state
.detail_metadata_header_viewport_rows(text.height, wrap_width)
.unwrap()
.start;
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x,
text.y + header_row,
),
area,
&mut state,
));
assert!(!state.detail_metadata_expanded());
}
#[test]
fn mouse_detail_wrapped_metadata_heading_accepts_continuation_row_click() {
let area = ratatui::layout::Rect::new(0, 0, 30, 30);
let mut state = state::MissionControlState::default();
state.apply_activity_event(crate::output::ActivityEvent::ToolResultDetail {
id: crate::output::ActivityId::new("wrapped-metadata-click"),
detail: crate::output::ToolActivityDetail {
tool_name: "read".into(),
label: "read file".into(),
params: serde_json::json!({"path":"file.txt"}),
metadata: serde_json::json!({"lines":1}),
status: crate::output::ActivityStatus::Success,
output: "visible output".into(),
applied_diff: None,
},
});
let detail = render::layout_for(area, &state)
.pane_area(render::TuiPane::Detail)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Detail, detail);
let wrap_width = render::scroll_view_content_width(text).max(1);
let header_rows = state
.detail_metadata_header_viewport_rows(text.height, wrap_width)
.unwrap();
assert!(header_rows.len() > 1);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x,
text.y + header_rows.end - 1,
),
area,
&mut state,
));
assert!(state.detail_metadata_expanded());
}
#[test]
fn mouse_transcript_click_keeps_selection_behavior() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
transcript: vec!["tool: ✓ read • done".to_string()].into(),
..Default::default()
};
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x + 4,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
assert!(!state.is_activity_tree_focused());
assert!(state.selection.is_some());
}
#[test]
fn mouse_prompt_drag_returns_copy_and_up_without_down_does_not() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("hello world", 0),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
let prompt = render::layout_for(area, &state)
.pane_area(render::TuiPane::Prompt)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Prompt, prompt);
let backend = ratatui::backend::TestBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|frame| render::draw(frame, &state)).unwrap();
let no_down = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 1,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(no_down.copy, None);
assert_eq!(state.prompt_cursor(), 1);
terminal.draw(|frame| render::draw(frame, &state)).unwrap();
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
terminal.draw(|frame| render::draw(frame, &state)).unwrap();
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
column: text.x + 5,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
terminal.draw(|frame| render::draw(frame, &state)).unwrap();
let copied = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 5,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("hello"));
assert!(state.selection.is_none());
}
#[test]
fn copy_request_updates_toast_for_success_failure_and_empty() {
let now = Instant::now();
let mut state = state::MissionControlState::default();
let mut ok = FakeClipboard {
result: Ok(()),
calls: 0,
};
assert!(apply_copy_request("text", &mut state, &mut ok, now));
assert_eq!(ok.calls, 1);
assert_eq!(state.toast.as_ref().unwrap().message, "Copied to clipboard");
let mut failed = FakeClipboard {
result: Err(anyhow::anyhow!("no clipboard")),
calls: 0,
};
assert!(apply_copy_request("text", &mut state, &mut failed, now));
assert_eq!(failed.calls, 1);
assert_eq!(
state.toast.as_ref().unwrap().message,
"Clipboard copy failed"
);
assert!(!apply_copy_request("", &mut state, &mut failed, now));
assert_eq!(failed.calls, 1);
}
#[test]
fn mouse_transcript_drag_copies_visible_text_without_soft_wrap_newline() {
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let mut state = state::MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "abcdef ghij".to_string(),
});
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
let wrap_width = render::transcript_scroll_view_content_width(text).max(1);
let (row, start, end) = 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);
let start = rendered.find("abcdef")?;
Some((row as u16, start, start + "abcdef".len()))
})
.expect("user message row");
handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x + u16::try_from(start).unwrap_or(u16::MAX),
text.y + row,
),
area,
&mut state,
);
handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
text.x + u16::try_from(end).unwrap_or(u16::MAX),
text.y + row,
),
area,
&mut state,
);
let copied = handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
text.x + u16::try_from(end).unwrap_or(u16::MAX),
text.y + row,
),
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("abcdef"));
}
#[test]
fn mouse_transcript_word_wrap_selection_keeps_spaces_and_no_soft_newline() {
let area = ratatui::layout::Rect::new(0, 0, 38, 24);
let mut state = state::MissionControlState {
transcript: vec!["you: alpha beta gamma delta".to_string()].into(),
..Default::default()
};
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
let wrap_width = render::transcript_scroll_view_content_width(text).max(1);
let lines = crate::tui::transcript_cards::visual_lines(&state, wrap_width);
let (first_row, first_start) = lines
.iter()
.enumerate()
.find_map(|(row, line)| {
let rendered = crate::tui::transcript_cards::rendered_line_text(&line.line);
let start = rendered.find("alpha")?;
Some((row, start))
})
.expect("first wrapped user row");
let (second_row, second_end) = lines
.iter()
.enumerate()
.find_map(|(row, line)| {
if row != first_row + 1 {
return None;
}
let rendered = crate::tui::transcript_cards::rendered_line_text(&line.line);
let end = rendered.find("gamma")?.saturating_add("gamma".len());
Some((row, end))
})
.expect("next wrapped user row");
assert_eq!(second_row, first_row + 1);
handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x + u16::try_from(first_start).unwrap_or(u16::MAX),
text.y + u16::try_from(first_row).unwrap_or(u16::MAX),
),
area,
&mut state,
);
handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
text.x + u16::try_from(second_end).unwrap_or(u16::MAX),
text.y + u16::try_from(second_row).unwrap_or(u16::MAX),
),
area,
&mut state,
);
let copied = handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
text.x + u16::try_from(second_end).unwrap_or(u16::MAX),
text.y + u16::try_from(second_row).unwrap_or(u16::MAX),
),
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("alpha beta gamma"));
assert!(!copied.copy.as_deref().unwrap_or_default().contains('\n'));
}
#[test]
fn session_picker_mouse_wheel_scrolls_list_and_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
(0..4)
.map(|index| state::SessionPickerRow {
id: format!("session-{index}"),
label: format!("session-{index}"),
is_current: false,
preview: None,
})
.collect(),
2,
);
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let list = render::sessions_modal_list_area(area).unwrap();
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
assert_eq!(state.modals.session_picker.as_ref().unwrap().selected, 1);
assert_eq!(state.transcript_scroll_offset(), 0);
}
#[test]
fn session_picker_mouse_wheel_at_bounds_is_bounded() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
(0..3)
.map(|index| state::SessionPickerRow {
id: format!("session-{index}"),
label: format!("session-{index}"),
is_current: false,
preview: None,
})
.collect(),
2,
);
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let list = render::sessions_modal_list_area(area).unwrap();
let at_top = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(!at_top.changed);
assert_eq!(state.modals.session_picker.as_ref().unwrap().selected, 0);
assert_eq!(state.scroll_offset(&state.scroll_views.session_list), 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
for _ in 0..4 {
let _ = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
}
let picker = state.modals.session_picker.as_ref().unwrap();
assert_eq!(picker.selected, 2);
assert_eq!(state.scroll_offset(&state.scroll_views.session_list), 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
}
#[test]
fn session_picker_preview_mouse_wheel_scrolls_preview_and_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
vec![state::SessionPickerRow {
id: "session-a".to_string(),
label: "session-a".to_string(),
is_current: false,
preview: None,
}],
2,
);
state.set_session_preview(
"session-a",
state::SessionPreviewState::Ready(state::SessionPreview {
first: state::PreviewMessage {
role: "user",
text: (0..40)
.map(|index| format!("preview line {index}"))
.collect::<Vec<_>>()
.join("\n"),
},
most_recent: None,
}),
);
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let preview = render::sessions_modal_preview_area(area).unwrap();
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: preview.x,
row: preview.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
let picker = state.modals.session_picker.as_ref().unwrap();
assert_eq!(picker.selected, 0);
assert!(state.scroll_offset(&state.scroll_views.session_preview) > 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
}
#[test]
fn skills_modal_mouse_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_skills_modal(
vec![state::SkillToggleRow {
name: "review".to_string(),
source: "test".to_string(),
enabled: true,
}],
input::DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
ratatui::layout::Rect::new(0, 0, 80, 24),
&mut state,
);
assert!(!result.changed);
assert_eq!(state.transcript_scroll_offset(), 0);
}
fn append_mouse_subagent_card(state: &mut state::MissionControlState, batch: &str) {
let batch_id = crate::output::ActivityId::new(batch);
let index = state.transcript.len();
state
.transcript
.push_back("tool: ⟳ subagents • running".to_string());
state.transcript.link_activity(
index,
crate::tui::transcript_cards::TranscriptActivityLink {
activity_id: batch_id.clone(),
tool_name: "subagents".to_string(),
},
);
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: batch_id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::SubagentBatch,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("subagents • 1 tasks"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new(format!("{batch}/g1")),
parent_id: Some(batch_id),
kind: crate::output::ActivityKind::SubagentTask,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("g1 • depth 1 • ordinary intent text"),
});
}
fn transcript_target_cell(
area: ratatui::layout::Rect,
state: &state::MissionControlState,
predicate: impl Fn(&crate::tui::transcript_cards::TranscriptHitTarget) -> bool,
) -> (u16, u16) {
let pane = render::layout_for(area, state)
.pane_area(render::TuiPane::Transcript)
.expect("transcript pane");
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, pane);
let width = render::transcript_scroll_view_content_width(text).max(1);
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
state,
width,
text.height,
state.transcript_scroll_offset(),
);
assert_eq!(visible.scroll_offset, 0);
for (row, line) in visible.lines.iter().enumerate() {
if let Some(region) = line
.hit_regions
.iter()
.find(|region| predicate(®ion.target))
{
return (
text.x.saturating_add(region.columns.start),
text.y.saturating_add(row as u16),
);
}
}
panic!("target not visible");
}
#[test]
fn subagent_card_mouse_routes_exact_typed_controls_without_stealing_text_selection() {
let area = ratatui::layout::Rect::new(0, 0, 120, 40);
let mut state = state::MissionControlState::default();
append_mouse_subagent_card(&mut state, "mouse-a");
append_mouse_subagent_card(&mut state, "mouse-b");
let batch_b = crate::output::ActivityId::new("mouse-b");
let task_b = crate::output::ActivityId::new("mouse-b/g1");
let (column, row) = transcript_target_cell(area, &state, |target| {
matches!(
target,
crate::tui::transcript_cards::TranscriptHitTarget::SelectTask {
batch_id,
task_id
} if batch_id == &batch_b && task_id == &task_b
)
});
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&mut state,
));
assert_eq!(
state.subagent_card_selection_for(&batch_b),
state::SubagentCardSelection {
task_id: Some(task_b.clone()),
}
);
let pane = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.expect("transcript pane");
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, pane);
let width = render::transcript_scroll_view_content_width(text).max(1);
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
&state,
width,
text.height,
state.transcript_scroll_offset(),
);
let (intent_row, intent_line) = visible
.lines
.iter()
.enumerate()
.find(|(_, line)| line.copy_text.contains("ordinary intent text"))
.expect("intent line");
assert!(intent_line.hit_regions.is_empty());
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x.saturating_add(intent_line.visual_copy_column_offset),
text.y.saturating_add(intent_row as u16),
),
area,
&mut state,
));
assert!(state.selection.is_some());
let (column, row) = transcript_target_cell(area, &state, |target| {
matches!(
target,
crate::tui::transcript_cards::TranscriptHitTarget::OpenTranscript {
batch_id,
task_id,
} if batch_id == &batch_b && task_id == &task_b
)
});
state.expanded.remove(&batch_b);
assert!(!state.expanded.contains(&batch_b));
let before = (
state.selected_activity_id(),
state.focus_pane,
state.expanded.clone(),
state.transcript_scroll_offset(),
state.detail_offset(),
state.selection,
);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&mut state,
));
assert!(state.subagent_viewer_visible());
assert_eq!(state.subagent_viewer_selected_task_id(), Some(task_b));
assert_eq!(state.selected_activity_id(), before.0);
assert_eq!(state.focus_pane, before.1);
assert_eq!(state.expanded, before.2);
assert_eq!(state.transcript_scroll_offset(), before.3);
assert_eq!(state.detail_offset(), before.4);
assert_eq!(state.selection, before.5);
}
#[test]
fn subagent_card_mouse_routes_typed_chip_at_nonzero_transcript_scrollback() {
let area = ratatui::layout::Rect::new(0, 0, 120, 40);
let mut state = state::MissionControlState::default();
append_mouse_subagent_card(&mut state, "scroll-batch");
for index in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("later transcript row {index}"),
});
}
let batch = crate::output::ActivityId::new("scroll-batch");
let task = crate::output::ActivityId::new("scroll-batch/g1");
let pane = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.expect("transcript pane");
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, pane);
let width = render::transcript_scroll_view_content_width(text).max(1);
let mut target_cell = None;
for scrollback in 1..200usize {
state.set_scroll_offset(&state.scroll_views.transcript, scrollback);
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
&state,
width,
text.height,
state.transcript_scroll_offset(),
);
for (row, line) in visible.lines.iter().enumerate() {
if let Some(region) = line.hit_regions.iter().find(|region| {
matches!(
®ion.target,
crate::tui::transcript_cards::TranscriptHitTarget::SelectTask {
batch_id,
task_id
} if batch_id == &batch && task_id == &task
)
}) {
target_cell = Some((
text.x.saturating_add(region.columns.start),
text.y.saturating_add(row as u16),
));
break;
}
}
if target_cell.is_some() {
break;
}
}
let (column, row) = target_cell.expect("scrolled subagent card chip visible");
assert!(state.transcript_scroll_offset() > 0);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&mut state,
));
assert_eq!(
state.subagent_card_selection_for(&batch),
state::SubagentCardSelection {
task_id: Some(task),
}
);
}
#[test]
fn right_column_tab_rail_clicks_use_tab_edges_and_consume_other_hits() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
state.focus_activity_detail();
let layout = render::layout_for(area, &state);
let rail = layout
.pane_area(render::TuiPane::RightColumnRail)
.expect("right-column rail");
for tab in [
crate::tui::state::RightColumnTab::Activity,
crate::tui::state::RightColumnTab::Summary,
] {
let tab_area = layout.right_column_tab_area(tab).expect("tab area");
for (column, row) in [
(tab_area.x, tab_area.y),
(
tab_area.x + tab_area.width - 1,
tab_area.y + tab_area.height - 1,
),
] {
assert_eq!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&state,
1,
),
Some(input::DisplayCommand::Focus(
input::FocusCommand::RightColumnTab(tab),
))
);
}
}
let activity_tab = layout
.right_column_tab_area(crate::tui::state::RightColumnTab::Activity)
.expect("activity tab area");
let separator_row = activity_tab.y + activity_tab.height;
assert_eq!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
rail.x,
separator_row,
),
area,
&state,
1,
),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
for kind in [
crossterm::event::MouseEventKind::ScrollDown,
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
] {
assert_eq!(
classify_mouse_event(mouse(kind, rail.x, separator_row), area, &state, 1),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
}
let summary_tab = layout
.right_column_tab_area(crate::tui::state::RightColumnTab::Summary)
.expect("summary tab area");
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
summary_tab.x,
summary_tab.y,
),
area,
&mut state,
));
assert!(state.is_prompt_focused());
let summary = render::layout_for(area, &state)
.pane_area(render::TuiPane::Summary)
.expect("summary pane");
for kind in [
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
] {
assert_eq!(
classify_mouse_event(mouse(kind, summary.x, summary.y), area, &state, 1),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
}
let text = render::content_text_area_for_pane(render::TuiPane::Summary, summary);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
text.x,
text.y
),
area,
&mut state
));
assert_eq!(state.summary.requested_enabled, Some(true));
state.summary.entries = vec!["summary line\n".repeat(100)];
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::ScrollDown,
text.x,
text.y + 2
),
area,
&mut state
));
assert!(state.summary.scroll > 0);
let activity_tab = render::layout_for(area, &state)
.right_column_tab_area(crate::tui::state::RightColumnTab::Activity)
.expect("activity tab area");
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
activity_tab.x,
activity_tab.y,
),
area,
&mut state,
));
assert!(state.is_activity_detail_focused());
}
fn assert_modal_owns_right_column_rail_click(
state: &mut state::MissionControlState,
area: ratatui::layout::Rect,
) {
let tab_area = render::layout_for(area, state)
.right_column_tab_area(crate::tui::state::RightColumnTab::Summary)
.expect("summary rail tab area");
let column = tab_area.x;
let row = tab_area.y;
let before = (state.right_column_tab, state.focus_pane);
assert_eq!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
state,
1,
),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
let result = handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
state,
);
assert!(!result.changed);
assert_eq!((state.right_column_tab, state.focus_pane), before);
}
#[test]
fn modal_mouse_owns_right_column_rail_clicks_without_changing_focus() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut system_prompt = state::MissionControlState::default();
system_prompt.open_system_prompt_modal("system prompt".to_string());
assert_modal_owns_right_column_rail_click(&mut system_prompt, area);
let mut usage = state::MissionControlState::default();
usage.open_usage_modal_loading();
assert_modal_owns_right_column_rail_click(&mut usage, area);
let mut model_picker = state::MissionControlState::default();
model_picker.open_model_picker(
vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-test",
)],
std::collections::BTreeSet::new(),
None,
10,
);
assert_modal_owns_right_column_rail_click(&mut model_picker, area);
}
#[test]
fn subagent_viewer_mouse_scrolls_timeline_and_switches_tabs_without_fallthrough() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
let batch = crate::output::ActivityId::new("mouse-viewer-batch");
let first = batch.child("g1");
let second = batch.child("g2");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: batch.clone(),
parent_id: None,
kind: crate::output::ActivityKind::SubagentBatch,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("subagents"),
});
for task in [&first, &second] {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: task.clone(),
parent_id: Some(batch.clone()),
kind: crate::output::ActivityKind::SubagentTask,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(task.as_str()),
});
}
let detail = batch.child("g1/detail");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: detail.clone(),
parent_id: Some(first.clone()),
kind: crate::output::ActivityKind::Diagnostic,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: detail,
preview: (0..30)
.map(|index| format!("viewer line {index}"))
.collect::<Vec<_>>()
.join("\n"),
});
assert!(state.open_subagent_viewer(batch, first.clone()));
state.selected = 7;
state.focus_pane = state::TuiFocusPane::ActivityDetail;
let underlying_before = (
state.selected_activity_id(),
state.focus_pane,
state.transcript_scroll_offset(),
state.detail_offset(),
);
let timeline = render::subagent_viewer_timeline_area(area).expect("viewer timeline");
let before_offset = state.subagent_viewer_scroll_offset(
timeline.height,
render::scroll_view_content_width(timeline).max(1),
);
assert!(matches!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::ScrollUp,
timeline.x,
timeline.y,
),
area,
&state,
3,
),
Some(input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::SubagentViewer,
down: false,
..
}))
));
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::ScrollUp,
timeline.x,
timeline.y,
),
area,
&mut state,
));
assert!(
state.subagent_viewer_scroll_offset(
timeline.height,
render::scroll_view_content_width(timeline).max(1),
) < before_offset
);
assert_eq!(
(
state.selected_activity_id(),
state.focus_pane,
state.transcript_scroll_offset(),
state.detail_offset(),
),
underlying_before
);
let mut second_tab_cell = None;
'cells: for row in area.y..area.y.saturating_add(area.height) {
for column in area.x..area.x.saturating_add(area.width) {
if render::subagent_viewer_tab_at(area, &state, column, row) == Some(1) {
second_tab_cell = Some((column, row));
break 'cells;
}
}
}
let (column, row) = second_tab_cell.expect("second viewer tab");
assert_eq!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&state,
3,
),
Some(input::DisplayCommand::Mouse(
input::MouseCommand::SelectSubagentViewerTab { visible_index: 1 }
))
);
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&mut state,
));
assert_eq!(state.subagent_viewer_selected_task_id(), Some(second));
assert_eq!(state.selected_activity_id(), underlying_before.0);
assert_eq!(
classify_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
area.x,
area.y,
),
area,
&state,
3,
),
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
);
}
#[test]
fn subagent_viewer_mouse_opens_for_a_tool_with_direct_sibling_tasks() {
let area = ratatui::layout::Rect::new(0, 0, 120, 40);
let mut state = state::MissionControlState::default();
let tool = crate::output::ActivityId::new("parent-tool");
let clicked = tool.child("g1");
let sibling = tool.child("g2");
let nested = clicked.child("nested");
state
.transcript
.push_back("tool: ⟳ subagents • running".to_string());
state.transcript.link_activity(
0,
crate::tui::transcript_cards::TranscriptActivityLink {
activity_id: tool.clone(),
tool_name: "subagents".to_string(),
},
);
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: tool.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("subagents • 2 tasks"),
});
for task in [&clicked, &sibling] {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: task.clone(),
parent_id: Some(tool.clone()),
kind: crate::output::ActivityKind::SubagentTask,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(
task.as_str().rsplit('/').next().unwrap_or(task.as_str()),
),
});
}
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: nested,
parent_id: Some(clicked.clone()),
kind: crate::output::ActivityKind::SubagentTask,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("nested task"),
});
let before = (state.selected_activity_id(), state.focus_pane);
let (column, row) = transcript_target_cell(area, &state, |target| {
matches!(
target,
crate::tui::transcript_cards::TranscriptHitTarget::OpenTranscript {
batch_id,
task_id,
} if batch_id == &tool && task_id == &clicked
)
});
assert!(handle_mouse_event(
mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column,
row,
),
area,
&mut state,
));
assert!(state.subagent_viewer_visible());
let viewer = state
.modals
.subagent_viewer
.as_ref()
.expect("subagent viewer");
assert_eq!(viewer.batch_id, tool);
assert_eq!(viewer.selected_task_id, clicked);
assert_eq!(
state.subagent_viewer_selected_task_id(),
Some(clicked.clone())
);
assert_eq!(state.subagent_viewer_tabs(), vec![clicked, sibling]);
assert_eq!((state.selected_activity_id(), state.focus_pane), before);
}
#[test]
fn summary_files_mouse_scroll_targets_hovered_section() {
let mut state = state::MissionControlState::default();
state.focus_summary();
state.summary.entries = vec!["summary\n".repeat(100)];
for index in 0..100 {
state.session_files.record(
"write",
true,
&serde_json::json!({"path":format!("{index:03}.rs")}),
&[],
);
}
let area = ratatui::layout::Rect::new(0, 0, 120, 50);
let summary = render::layout_for(area, &state).summary.unwrap();
let files = render::summary_section_body(summary, true);
let log = render::summary_section_body(summary, false);
let down = crossterm::event::MouseEventKind::ScrollDown;
assert!(handle_mouse_event(
mouse(down, files.x, files.y),
area,
&mut state
));
assert!(state.session_files.scroll > 0);
assert_eq!(state.summary.scroll, 0);
let before = state.session_files.scroll;
assert!(handle_mouse_event(
mouse(down, log.x, log.y),
area,
&mut state
));
assert!(state.summary.scroll > 0);
assert_eq!(state.session_files.scroll, before);
let click = crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left);
assert!(handle_mouse_event(
mouse(click, files.x, files.y),
area,
&mut state
));
assert!(state.session_files.focused);
}