use crate::tui::{input, render, state};
use crossterm::event::KeyEventKind;
use std::time::Instant;
#[derive(Debug, Default)]
pub(super) struct DisplayApplyResult {
pub(super) changed: bool,
pub(super) copy: Option<String>,
}
impl DisplayApplyResult {
fn changed(changed: bool) -> Self {
Self {
changed,
copy: None,
}
}
fn merge(&mut self, other: Self) {
self.changed |= other.changed;
if self.copy.is_none() {
self.copy = other.copy;
}
}
}
pub(super) fn apply_display_command(
command: input::DisplayCommand,
ui_state: &mut state::MissionControlState,
autocomplete_candidates: &[state::AutocompleteCandidate],
) -> DisplayApplyResult {
match command {
input::DisplayCommand::Batch(commands) => {
let mut result = DisplayApplyResult::default();
for command in commands {
result.merge(apply_display_command(
command,
ui_state,
autocomplete_candidates,
));
}
result
}
input::DisplayCommand::ClearStandaloneCtrl { suppress_release } => {
let before = (
ui_state.last_standalone_ctrl,
ui_state.ignore_next_standalone_ctrl_release,
);
ui_state.clear_pending_standalone_ctrl();
if suppress_release {
ui_state.suppress_next_standalone_ctrl_release();
}
DisplayApplyResult::changed(
before
!= (
ui_state.last_standalone_ctrl,
ui_state.ignore_next_standalone_ctrl_release,
),
)
}
input::DisplayCommand::StandaloneCtrl { kind, now } => {
apply_standalone_ctrl(kind, now, ui_state)
}
input::DisplayCommand::ToggleSummary => {
ui_state.focus_summary();
ui_state.summary.requested_enabled = Some(
!ui_state
.summary
.requested_enabled
.unwrap_or(ui_state.summary.enabled),
);
DisplayApplyResult::changed(true)
}
input::DisplayCommand::FocusSummarySection { files } => {
ui_state.focus_summary();
ui_state.session_files.focused = files;
DisplayApplyResult::changed(true)
}
input::DisplayCommand::ScrollSummary {
files,
down,
rows,
width,
height,
} => {
let max = if files {
ui_state.session_files.max_scroll(height)
} else {
ui_state.summary.max_scroll(width.saturating_sub(1), height)
};
let scroll = if files {
&mut ui_state.session_files.scroll
} else {
&mut ui_state.summary.scroll
};
let before = *scroll;
*scroll = if down {
before.min(max).saturating_add(rows).min(max)
} else {
before.min(max).saturating_sub(rows)
};
DisplayApplyResult::changed(before != *scroll)
}
input::DisplayCommand::Focus(command) => apply_focus_command(command, ui_state),
input::DisplayCommand::Prompt(command) => {
apply_prompt_command(command, ui_state, autocomplete_candidates)
}
input::DisplayCommand::Activity(command) => apply_activity_command(command, ui_state),
input::DisplayCommand::Scroll(command) => apply_scroll_command(command, ui_state),
input::DisplayCommand::Modal(command) => apply_modal_command(command, ui_state),
input::DisplayCommand::Mouse(command) => apply_mouse_command(command, ui_state),
input::DisplayCommand::SetPrimaryAgent(id) => {
let changed = ui_state.set_selected_primary_agent_id(id.as_deref());
DisplayApplyResult::changed(changed)
}
input::DisplayCommand::SetThinkingLevel(level) => {
let available = ui_state.thinking_levels.clone();
let changed = ui_state.thinking_level != level;
ui_state.refresh_thinking_levels(level, available);
DisplayApplyResult::changed(changed)
}
input::DisplayCommand::RefreshAutocomplete => {
refresh_autocomplete(ui_state, autocomplete_candidates);
DisplayApplyResult::changed(true)
}
input::DisplayCommand::SetStatus(status) => {
let changed = ui_state.status != status;
ui_state.status = status;
DisplayApplyResult::changed(changed)
}
}
}
fn apply_standalone_ctrl(
kind: KeyEventKind,
now: Instant,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
match kind {
KeyEventKind::Press => {
if ui_state
.last_standalone_ctrl
.is_some_and(|last| now.duration_since(last) <= DOUBLE_CTRL_WINDOW)
{
ui_state.focus_prompt();
ui_state.show_help = false;
ui_state.clear_pending_standalone_ctrl();
} else {
ui_state.arm_standalone_ctrl(now);
}
ui_state.suppress_next_standalone_ctrl_release();
DisplayApplyResult::changed(true)
}
KeyEventKind::Release => {
if !ui_state.take_suppressed_standalone_ctrl_release() {
if ui_state
.last_standalone_ctrl
.is_some_and(|last| now.duration_since(last) <= DOUBLE_CTRL_WINDOW)
{
ui_state.focus_prompt();
ui_state.show_help = false;
ui_state.clear_pending_standalone_ctrl();
} else {
ui_state.arm_standalone_ctrl(now);
}
}
DisplayApplyResult::changed(true)
}
KeyEventKind::Repeat => DisplayApplyResult::changed(false),
}
}
const DOUBLE_CTRL_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
fn apply_focus_command(
command: input::FocusCommand,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
match command {
input::FocusCommand::Prompt { close_help } => {
ui_state.focus_prompt();
if close_help {
ui_state.show_help = false;
}
DisplayApplyResult::changed(true)
}
input::FocusCommand::ActivityTree => {
ui_state.focus_activity_tree();
DisplayApplyResult::changed(true)
}
input::FocusCommand::RightColumnTab(tab) => {
DisplayApplyResult::changed(ui_state.select_right_column_tab(tab))
}
input::FocusCommand::NextPrimaryPane => {
ui_state.focus_next_primary_pane();
DisplayApplyResult::changed(true)
}
input::FocusCommand::ToggleHelp => {
ui_state.show_help = !ui_state.show_help;
if ui_state.show_help {
ui_state.set_scroll_offset(&ui_state.scroll_views.help, 0);
}
DisplayApplyResult::changed(true)
}
input::FocusCommand::ToggleActivityColumn => {
ui_state.toggle_activity_column();
DisplayApplyResult::changed(true)
}
input::FocusCommand::TranscriptLayout => {
ui_state.focus_transcript_layout();
DisplayApplyResult::changed(true)
}
input::FocusCommand::ActivityLayout => {
ui_state.focus_activity_layout();
DisplayApplyResult::changed(true)
}
}
}
fn apply_prompt_command(
command: input::PromptCommand,
ui_state: &mut state::MissionControlState,
autocomplete_candidates: &[state::AutocompleteCandidate],
) -> DisplayApplyResult {
let before_revision = ui_state.prompt_content_revision();
let before_status = ui_state.status.clone();
let mut refresh = false;
match command {
input::PromptCommand::Edit {
operation,
viewport,
refresh_autocomplete,
} => {
use input::PromptEditCommand;
match operation {
PromptEditCommand::InsertChar(ch) => {
ui_state.insert_prompt_char(ch, viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::InsertNewline => {
ui_state.insert_prompt_char('\n', viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::Backspace => {
ui_state.backspace_prompt_char(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::Delete => {
ui_state.delete_prompt_char(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveCursorLeft => {
ui_state.move_prompt_cursor_left(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveCursorRight => {
ui_state.move_prompt_cursor_right(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveCursorUp => {
ui_state.move_prompt_cursor_up(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveCursorDown => {
ui_state.move_prompt_cursor_down(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveWordLeft => {
ui_state.move_prompt_word_left(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::MoveWordRight => {
ui_state.move_prompt_word_right(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::Undo => {
ui_state.undo_prompt_edit();
}
PromptEditCommand::Redo => {
ui_state.redo_prompt_edit();
}
PromptEditCommand::DeleteWord => {
ui_state.delete_prompt_word();
}
PromptEditCommand::DeleteNextWord => {
ui_state.delete_prompt_next_word();
}
PromptEditCommand::ExtendSelectionLeft => {
ui_state
.extend_prompt_selection_left(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::ExtendSelectionRight => {
ui_state
.extend_prompt_selection_right(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::ExtendSelectionUp => {
ui_state.extend_prompt_selection_up(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::ExtendSelectionDown => {
ui_state
.extend_prompt_selection_down(viewport.visible_rows, viewport.wrap_width);
}
PromptEditCommand::SelectAll => ui_state.select_all_prompt(),
PromptEditCommand::Clear => ui_state.clear_prompt_input(),
}
refresh = refresh_autocomplete;
}
input::PromptCommand::InsertText {
text,
viewport,
refresh_autocomplete,
} => {
ui_state.insert_prompt_str(&text, viewport.visible_rows, viewport.wrap_width);
refresh = refresh_autocomplete;
}
input::PromptCommand::TakeForSubmit => {
let _ = ui_state.take_prompt_for_submit();
refresh = true;
}
input::PromptCommand::AcceptAutocomplete { viewport } => {
ui_state.accept_autocomplete(viewport.visible_rows, viewport.wrap_width);
}
input::PromptCommand::MoveAutocompleteUp => {
ui_state.move_autocomplete_selection_up();
}
input::PromptCommand::MoveAutocompleteDown => {
ui_state.move_autocomplete_selection_down();
}
input::PromptCommand::DismissAutocomplete => {
ui_state.dismiss_autocomplete_for_current_token();
}
}
if refresh {
refresh_autocomplete(ui_state, autocomplete_candidates);
}
DisplayApplyResult::changed(
ui_state.prompt_content_revision() != before_revision
|| ui_state.status != before_status
|| refresh,
)
}
fn refresh_autocomplete(
ui_state: &mut state::MissionControlState,
autocomplete_candidates: &[state::AutocompleteCandidate],
) {
if ui_state.is_prompt_focused() {
ui_state.recompute_autocomplete(autocomplete_candidates);
} else {
ui_state.hide_autocomplete();
}
}
fn apply_activity_command(
command: input::ActivityCommand,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
let before_selected = ui_state.selected;
let before_scroll = ui_state.activity_scroll_offset();
let before_expanded = ui_state.expanded.len();
let before_follow_tail = ui_state.activity_follow_tail;
let has_activity = ui_state.visible_node_count() != 0;
match command {
input::ActivityCommand::MoveUp { visible_rows } if has_activity => {
ui_state.selected = ui_state.selected.saturating_sub(1);
ui_state.activity_follow_tail = false;
if ui_state.selected != before_selected {
ui_state.reset_detail();
}
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::MoveDown { visible_rows } if has_activity => {
let max = ui_state.visible_node_count().saturating_sub(1);
ui_state.selected = ui_state.selected.saturating_add(1).min(max);
ui_state.activity_follow_tail = ui_state.selected >= max;
if ui_state.selected != before_selected {
ui_state.reset_detail();
}
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::CollapseSelected { visible_rows } if has_activity => {
let previous_selection = ui_state.selected_activity_id();
if let Some(id) = ui_state.visible_node_id_at(ui_state.selected)
&& ui_state.has_activity_children(&id)
&& ui_state.expanded.remove(&id)
{
ui_state.invalidate_activity_render_cache();
}
ui_state.normalize_selection_after_visible_change(previous_selection);
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::ExpandSelected { visible_rows } if has_activity => {
let previous_selection = ui_state.selected_activity_id();
if let Some(id) = ui_state.visible_node_id_at(ui_state.selected)
&& ui_state.has_activity_children(&id)
&& ui_state.expanded.insert(id)
{
ui_state.invalidate_activity_render_cache();
}
ui_state.normalize_selection_after_visible_change(previous_selection);
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::ExpandAll { visible_rows } if has_activity => {
let previous_selection = ui_state.selected_activity_id();
let expandable_ids = ui_state
.children
.iter()
.filter_map(|(id, children)| (!children.is_empty()).then_some(id.clone()))
.collect::<Vec<_>>();
for id in expandable_ids {
ui_state.expanded.insert(id);
}
ui_state.invalidate_activity_render_cache();
if let Some(previous_selection) = previous_selection.as_ref()
&& let Some(selected) = ui_state
.visible_activity_ids()
.iter()
.position(|id| id == previous_selection)
{
ui_state.selected = selected;
}
ui_state.normalize_selection_after_visible_change(previous_selection);
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::CollapseAll { visible_rows } if has_activity => {
let previous_selection = ui_state.selected_activity_id();
ui_state.expanded.clear();
ui_state.invalidate_activity_render_cache();
if let Some(previous_selection) = previous_selection.as_ref()
&& let Some(selected) = ui_state
.visible_activity_ids()
.iter()
.position(|id| id == previous_selection)
{
ui_state.selected = selected;
}
ui_state.normalize_selection_after_visible_change(previous_selection);
ui_state.ensure_activity_selection_visible(visible_rows);
}
input::ActivityCommand::SelectStatus {
status,
visible_rows,
} if has_activity => {
select_status(ui_state, status);
ui_state.ensure_activity_selection_visible(visible_rows);
}
_ => {}
}
DisplayApplyResult::changed(
ui_state.selected != before_selected
|| ui_state.activity_scroll_offset() != before_scroll
|| ui_state.expanded.len() != before_expanded
|| ui_state.activity_follow_tail != before_follow_tail,
)
}
fn select_status(state: &mut state::MissionControlState, status: crate::output::ActivityStatus) {
let visible_ids = state.visible_activity_ids();
for (index, id) in visible_ids.iter().enumerate() {
let selected = state.nodes.get(id).is_some_and(|node| {
node.status == status
|| (status == crate::output::ActivityStatus::Running
&& node.status == crate::output::ActivityStatus::Writing)
});
if selected {
if state.selected != index {
state.selected = index;
state.activity_follow_tail = index >= visible_ids.len().saturating_sub(1);
state.reset_detail();
}
break;
}
}
}
fn apply_scroll_command(
command: input::ScrollCommand,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
let changed = match command.target {
input::ScrollTarget::Transcript => {
if command.down {
ui_state.scroll_transcript_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_transcript_up_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
}
}
input::ScrollTarget::ActivityTree => {
if command.down {
ui_state.scroll_activity_tree_down_by(command.rows, command.visible_rows)
} else {
ui_state.scroll_activity_tree_up_by(command.rows, command.visible_rows)
}
}
input::ScrollTarget::Detail => {
if command.down {
ui_state.scroll_detail_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_detail_up_by(command.rows, command.visible_rows, command.wrap_width)
}
}
input::ScrollTarget::Help => {
if command.down {
ui_state.scroll_help_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
input::help_text(),
)
} else {
ui_state.scroll_help_up_by(
command.rows,
command.visible_rows,
command.wrap_width,
input::help_text(),
)
}
}
input::ScrollTarget::SystemPrompt => {
if command.down {
ui_state.scroll_system_prompt_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_system_prompt_up_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
}
}
input::ScrollTarget::Rewind => {
if command.down {
ui_state.scroll_rewind_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_rewind_up_by(command.rows, command.visible_rows, command.wrap_width)
}
}
input::ScrollTarget::Usage => {
if command.down {
ui_state.scroll_usage_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_usage_up_by(command.rows, command.visible_rows, command.wrap_width)
}
}
input::ScrollTarget::SessionPreview => {
if command.down {
ui_state.session_picker_preview_down_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.session_picker_preview_up_by(
command.rows,
command.visible_rows,
command.wrap_width,
)
}
}
input::ScrollTarget::SubagentViewer => {
if command.down {
ui_state.scroll_subagent_viewer_down(
command.rows,
command.visible_rows,
command.wrap_width,
)
} else {
ui_state.scroll_subagent_viewer_up(
command.rows,
command.visible_rows,
command.wrap_width,
)
}
}
};
DisplayApplyResult::changed(changed)
}
fn apply_modal_command(
command: input::ModalCommand,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
let changed = match command {
input::ModalCommand::SessionPickerFocusNext => ui_state.session_picker_focus_next(),
input::ModalCommand::SessionPickerFocusPrevious => ui_state.session_picker_focus_previous(),
input::ModalCommand::SessionPickerMove {
focus,
down,
amount,
visible_rows,
preview_rows,
preview_width,
} => match focus {
state::SessionPickerFocus::List => {
let page = amount == visible_rows.max(1);
if page {
if down {
ui_state.session_picker_page_down(visible_rows)
} else {
ui_state.session_picker_page_up(visible_rows)
}
} else {
let mut changed = false;
for _ in 0..amount {
changed |= if down {
ui_state.session_picker_down(visible_rows)
} else {
ui_state.session_picker_up(visible_rows)
};
}
changed
}
}
state::SessionPickerFocus::Preview => {
let rows = amount.min(usize::from(u16::MAX)) as u16;
if down {
ui_state.session_picker_preview_down_by(rows, preview_rows, preview_width)
} else {
ui_state.session_picker_preview_up_by(rows, preview_rows, preview_width)
}
}
},
input::ModalCommand::CloseSessionPicker => ui_state.close_session_picker(),
input::ModalCommand::ThemeMove {
amount,
down,
visible_rows,
} => {
let selected = if amount == visible_rows.max(1) {
if down {
ui_state.theme_picker_page_down(visible_rows)
} else {
ui_state.theme_picker_page_up(visible_rows)
}
} else if down {
ui_state.theme_picker_down(visible_rows)
} else {
ui_state.theme_picker_up(visible_rows)
};
selected.is_some()
}
input::ModalCommand::ThemeInsertChar(ch) => ui_state.theme_picker_insert_char(ch).is_some(),
input::ModalCommand::ThemeBackspace => ui_state.theme_picker_backspace().is_some(),
input::ModalCommand::ThemeDelete => ui_state.theme_picker_delete().is_some(),
input::ModalCommand::ThemeCursorLeft => {
ui_state.theme_picker_cursor_left();
true
}
input::ModalCommand::ThemeCursorRight => {
ui_state.theme_picker_cursor_right();
true
}
input::ModalCommand::ThemeCursorHome => {
ui_state.theme_picker_cursor_home();
true
}
input::ModalCommand::ThemeCursorEnd => {
ui_state.theme_picker_cursor_end();
true
}
input::ModalCommand::MoveSettings {
kind,
down,
visible_rows,
} => match kind {
input::SettingsModalKind::Skills => {
if down {
ui_state.skills_modal_down(visible_rows)
} else {
ui_state.skills_modal_up(visible_rows)
}
}
input::SettingsModalKind::Mcp => {
if down {
ui_state.mcp_modal_down(visible_rows)
} else {
ui_state.mcp_modal_up(visible_rows)
}
}
input::SettingsModalKind::Tools => {
if down {
ui_state.tools_modal_down(visible_rows)
} else {
ui_state.tools_modal_up(visible_rows)
}
}
input::SettingsModalKind::Subagents => {
if down {
ui_state.subagents_modal_down(visible_rows)
} else {
ui_state.subagents_modal_up(visible_rows)
}
}
input::SettingsModalKind::Models => {
if down {
ui_state.models_modal_down(visible_rows)
} else {
ui_state.models_modal_up(visible_rows)
}
}
},
input::ModalCommand::SetSettingRow { kind, key, enabled } => {
match kind {
input::SettingsModalKind::Skills => ui_state.set_skill_row_enabled(&key, enabled),
input::SettingsModalKind::Mcp => ui_state.set_mcp_server_row_enabled(&key, enabled),
input::SettingsModalKind::Tools => ui_state.set_tool_row_enabled(&key, enabled),
input::SettingsModalKind::Subagents => {
ui_state.set_subagent_profile_row_enabled(&key, enabled)
}
input::SettingsModalKind::Models => ui_state.set_model_row_enabled(&key, enabled),
}
true
}
input::ModalCommand::CloseSettings(kind) => match kind {
input::SettingsModalKind::Skills => ui_state.close_skills_modal(),
input::SettingsModalKind::Mcp => ui_state.close_mcp_modal(),
input::SettingsModalKind::Tools => ui_state.close_tools_modal(),
input::SettingsModalKind::Subagents => ui_state.close_subagents_modal(),
input::SettingsModalKind::Models => ui_state.close_models_modal(),
},
input::ModalCommand::CloseSystemPrompt => ui_state.close_system_prompt_modal(),
input::ModalCommand::CloseUsage => ui_state.close_usage_modal(),
input::ModalCommand::RewindPickerMoveOrScroll { down, viewport } => {
let moved = if down {
ui_state.rewind_picker_down()
} else {
ui_state.rewind_picker_up()
};
if moved {
true
} else if down {
ui_state.scroll_rewind_down_by(1, viewport.visible_rows, viewport.wrap_width)
} else {
ui_state.scroll_rewind_up_by(1, viewport.visible_rows, viewport.wrap_width)
}
}
input::ModalCommand::CycleRewindMode => {
if let Some(modal) = ui_state.modals.rewind_modal.as_mut()
&& modal.show_mode_choices
{
modal.mode = modal.mode.next();
true
} else {
false
}
}
input::ModalCommand::CloseRewind => ui_state.close_rewind_modal(),
input::ModalCommand::CloseLogoutConfirmation => ui_state.close_logout_confirmation(),
input::ModalCommand::CloseLogoutPicker => ui_state.close_logout_picker(),
input::ModalCommand::LogoutPickerMove { down, visible_rows } => {
if down {
ui_state.logout_picker_down(visible_rows)
} else {
ui_state.logout_picker_up(visible_rows)
}
}
input::ModalCommand::CloseModelPicker => ui_state.close_model_picker(),
input::ModalCommand::ModelPickerMove { down, visible_rows } => {
if down {
ui_state.model_picker_down(visible_rows)
} else {
ui_state.model_picker_up(visible_rows)
}
}
input::ModalCommand::CloseSubagentViewer => ui_state.close_subagent_viewer(),
input::ModalCommand::SubagentViewerSelectNext => ui_state.select_next_subagent_viewer_tab(),
input::ModalCommand::SubagentViewerSelectPrevious => {
ui_state.select_previous_subagent_viewer_tab()
}
input::ModalCommand::SubagentViewerScrollHome => ui_state.home_subagent_viewer(),
input::ModalCommand::SubagentViewerScrollEnd { viewport } => {
ui_state.end_subagent_viewer(viewport.visible_rows, viewport.wrap_width)
}
};
DisplayApplyResult::changed(changed)
}
fn apply_mouse_command(
command: input::MouseCommand,
ui_state: &mut state::MissionControlState,
) -> DisplayApplyResult {
match command {
input::MouseCommand::Consume => DisplayApplyResult::changed(false),
input::MouseCommand::ClearSelection => {
DisplayApplyResult::changed(ui_state.clear_selection())
}
input::MouseCommand::StartPromptSelection { byte } => {
ui_state.start_prompt_selection_at(byte);
DisplayApplyResult::changed(true)
}
input::MouseCommand::UpdatePromptSelection { byte } => {
DisplayApplyResult::changed(ui_state.update_prompt_selection_at(byte))
}
input::MouseCommand::FinishPromptSelection {
byte,
visible_rows,
wrap_width,
} => {
let copy = ui_state.finish_selection(render::TuiPane::Prompt, wrap_width);
if let Some(copy) = copy {
DisplayApplyResult {
changed: true,
copy: Some(copy),
}
} else if let Some(byte) = byte {
ui_state.move_prompt_cursor_to(byte, visible_rows, wrap_width);
DisplayApplyResult::changed(true)
} else {
DisplayApplyResult::changed(false)
}
}
input::MouseCommand::StartTranscriptSelection { position } => {
ui_state.start_selection(render::TuiPane::Transcript, position);
DisplayApplyResult::changed(true)
}
input::MouseCommand::UpdateTranscriptSelection { position } => DisplayApplyResult::changed(
ui_state.update_selection(render::TuiPane::Transcript, position),
),
input::MouseCommand::FinishTranscriptSelection { wrap_width } => {
let copy = ui_state.finish_selection(render::TuiPane::Transcript, wrap_width);
DisplayApplyResult {
changed: copy.is_some(),
copy,
}
}
input::MouseCommand::ClickActivityTree { row, visible_rows } => {
let selection_changed = ui_state.clear_selection();
let focus_changed = !ui_state.is_activity_tree_focused();
ui_state.focus_activity_tree();
let activity_changed = ui_state.click_activity_tree_row(row, visible_rows);
DisplayApplyResult::changed(selection_changed || focus_changed || activity_changed)
}
input::MouseCommand::ClickActivityDetail {
row,
visible_rows,
wrap_width,
} => {
let selection_changed = ui_state.clear_selection();
let focus_changed = !ui_state.is_activity_detail_focused();
ui_state.focus_activity_detail();
let metadata_clicked = ui_state
.detail_metadata_header_viewport_rows(visible_rows, wrap_width)
.is_some_and(|header_rows| header_rows.contains(&row))
&& ui_state.toggle_detail_metadata(visible_rows, wrap_width);
DisplayApplyResult::changed(selection_changed || focus_changed || metadata_clicked)
}
input::MouseCommand::SelectSessionRow {
visible_index,
visible_rows,
} => DisplayApplyResult::changed(
ui_state.session_picker_select_visible_row(visible_index, visible_rows),
),
input::MouseCommand::PinSubagentCard { batch_id, task_id } => {
ui_state.clear_selection();
ui_state.pin_subagent_card_task(batch_id, task_id);
DisplayApplyResult::changed(true)
}
input::MouseCommand::SelectSubagentViewerTab { visible_index } => {
DisplayApplyResult::changed(ui_state.select_subagent_viewer_tab(visible_index))
}
input::MouseCommand::OpenSubagentViewer { batch_id, task_id } => {
DisplayApplyResult::changed(ui_state.open_subagent_viewer(batch_id, task_id))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn summary_toggle_before_ack_reverses_submitted_intent() {
let mut ui = state::MissionControlState::default();
apply_display_command(input::DisplayCommand::ToggleSummary, &mut ui, &[]);
assert_eq!(ui.summary.take_requested_enabled(), Some(true));
apply_display_command(input::DisplayCommand::ToggleSummary, &mut ui, &[]);
assert_eq!(ui.summary.take_requested_enabled(), Some(false));
ui.summary.apply(crate::summarizer::SummarySnapshot {
session_id: "primary".into(),
enabled: false,
running: false,
entries: vec![],
error: Some("worker unavailable".into()),
});
apply_display_command(input::DisplayCommand::ToggleSummary, &mut ui, &[]);
assert_eq!(ui.summary.take_requested_enabled(), Some(true));
}
fn add_activity(state: &mut state::MissionControlState, id: &str, parent_id: Option<&str>) {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new(id),
parent_id: parent_id.map(crate::output::ActivityId::new),
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Success,
metadata: crate::output::ActivityMetadata::new(id),
});
}
#[test]
fn display_reducer_applies_classified_prompt_edits_and_refreshes_autocomplete() {
let candidates = vec![state::AutocompleteCandidate::slash_command(
"new",
"start a new session",
)];
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
prompt_editor: crate::tui::state::PromptEditor::new("/n", 2),
..Default::default()
};
let command = input::classify_key_with_all_viewports(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
&state,
input::KeyViewports::default(),
&candidates,
);
let (_, display) = command.into_parts();
let result = apply_display_command(
display.expect("classified prompt display command"),
&mut state,
&candidates,
);
assert!(result.changed);
assert_eq!(state.prompt_plain_text(), "/ne");
assert_eq!(
state
.autocomplete
.as_ref()
.and_then(|autocomplete| autocomplete.candidates.first())
.map(|candidate| candidate.name.as_str()),
Some("new")
);
let mut paste_state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let paste = input::classify_paste_with_all_viewports(
"/n",
&paste_state,
input::KeyViewports::default(),
&candidates,
);
let (_, display) = paste.into_parts();
apply_display_command(
display.expect("classified paste display command"),
&mut paste_state,
&candidates,
);
assert_eq!(paste_state.prompt_plain_text(), "/n");
assert!(paste_state.autocomplete_visible());
}
#[test]
fn autocomplete_acceptance_through_classifier_and_reducer_closes_suggestion() {
let candidates = vec![state::AutocompleteCandidate::slash_command(
"new",
"start a new session",
)];
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
prompt_editor: crate::tui::state::PromptEditor::new("/n", 2),
..Default::default()
};
state.recompute_autocomplete(&candidates);
assert!(state.autocomplete_visible());
let command = input::classify_key_with_all_viewports(
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
&state,
input::KeyViewports::default(),
&candidates,
);
let (_, display) = command.into_parts();
let result = apply_display_command(
display.expect("classified autocomplete command"),
&mut state,
&candidates,
);
assert!(result.changed);
assert_eq!(state.prompt_plain_text(), "/new");
assert!(!state.autocomplete_visible());
}
#[test]
fn rewind_mode_tab_cycles_three_choices_and_confirmation_keeps_choice() {
let mut state = state::MissionControlState::default();
let plan = crate::checkpoints::RestorePlan {
session_id: "session".into(),
target_turn: 1,
latest_turn: None,
operations: Vec::new(),
diagnostics: Vec::new(),
};
state.open_rewind_picker(vec![plan]);
assert_eq!(
state.modals.rewind_modal.as_ref().unwrap().mode,
crate::checkpoints::RewindMode::Conversation
);
for expected in [
crate::checkpoints::RewindMode::Files,
crate::checkpoints::RewindMode::Both,
] {
let command = input::classify_key_with_all_viewports(
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
&state,
input::KeyViewports {
rewind_modal: Some(input::SystemPromptViewport {
visible_rows: 10,
wrap_width: 80,
}),
..Default::default()
},
&[],
);
let (_, display) = command.into_parts();
assert!(apply_display_command(display.unwrap(), &mut state, &[]).changed);
assert_eq!(state.modals.rewind_modal.as_ref().unwrap().mode, expected);
}
let (plan, mode) = state.take_rewind_plan().unwrap();
assert_eq!(plan.target_turn, 1);
assert_eq!(mode, crate::checkpoints::RewindMode::Both);
assert!(!state.rewind_modal_visible());
}
#[test]
fn rewind_picker_scrolls_at_plan_boundaries_without_resetting_offset() {
let plan = |target_turn| crate::checkpoints::RestorePlan {
session_id: "session".to_string(),
target_turn,
latest_turn: Some(3),
operations: Vec::new(),
diagnostics: Vec::new(),
};
let viewport = input::SystemPromptViewport {
visible_rows: 3,
wrap_width: 40,
};
for (key, selected, initial_offset, expected_offset) in [
(KeyCode::Up, 0, 2usize, 1usize),
(KeyCode::Down, 1, 2usize, 3usize),
] {
let mut state = state::MissionControlState::default();
state.open_rewind_picker(vec![plan(1), plan(2)]);
let modal = state.modals.rewind_modal.as_mut().expect("rewind picker");
modal.selected = selected;
modal.content = (0..30)
.map(|line| format!("rewind line {line}"))
.collect::<Vec<_>>()
.join("\n");
state.set_scroll_offset(&state.scroll_views.rewind_modal, initial_offset);
let command = input::classify_key_with_all_viewports(
KeyEvent::new(key, KeyModifiers::NONE),
&state,
input::KeyViewports {
rewind_modal: Some(viewport),
..input::KeyViewports::default()
},
&[],
);
let (_, display) = command.into_parts();
assert!(matches!(
&display,
Some(input::DisplayCommand::Batch(commands))
if matches!(
commands.last(),
Some(input::DisplayCommand::Modal(
input::ModalCommand::RewindPickerMoveOrScroll { .. }
))
)
));
let result =
apply_display_command(display.expect("classified rewind command"), &mut state, &[]);
assert!(result.changed);
assert_eq!(
state
.modals
.rewind_modal
.as_ref()
.expect("rewind picker")
.selected,
selected
);
assert_eq!(
state.scroll_offset(&state.scroll_views.rewind_modal),
expected_offset
);
}
}
#[test]
fn expand_all_reveals_nested_descendants_and_preserves_selection_identity() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
};
add_activity(&mut state, "root-a", None);
add_activity(&mut state, "child-a", Some("root-a"));
add_activity(&mut state, "grandchild-a", Some("child-a"));
add_activity(&mut state, "root-b", None);
add_activity(&mut state, "root-c", None);
state.expanded.clear();
state.invalidate_activity_render_cache();
state.selected = 1;
assert_eq!(
state.selected_activity_id().as_ref().map(|id| id.as_str()),
Some("root-b")
);
let result = apply_display_command(
input::DisplayCommand::Activity(input::ActivityCommand::ExpandAll { visible_rows: 10 }),
&mut state,
&[],
);
assert!(result.changed);
assert!(
state
.expanded
.contains(&crate::output::ActivityId::new("root-a"))
);
assert!(
state
.expanded
.contains(&crate::output::ActivityId::new("child-a"))
);
assert_eq!(
state.visible_activity_ids(),
vec![
crate::output::ActivityId::new("root-a"),
crate::output::ActivityId::new("child-a"),
crate::output::ActivityId::new("grandchild-a"),
crate::output::ActivityId::new("root-b"),
crate::output::ActivityId::new("root-c"),
]
);
assert_eq!(
state.selected_activity_id().as_ref().map(|id| id.as_str()),
Some("root-b")
);
}
#[test]
fn collapse_all_captures_and_restores_visible_selection_identity() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
};
add_activity(&mut state, "root-a", None);
add_activity(&mut state, "child-a", Some("root-a"));
add_activity(&mut state, "grandchild-a", Some("child-a"));
add_activity(&mut state, "root-b", None);
add_activity(&mut state, "root-c", None);
state.expanded.clear();
state.invalidate_activity_render_cache();
apply_display_command(
input::DisplayCommand::Activity(input::ActivityCommand::ExpandAll { visible_rows: 10 }),
&mut state,
&[],
);
state.selected = 3;
assert_eq!(
state.selected_activity_id().as_ref().map(|id| id.as_str()),
Some("root-b")
);
apply_display_command(
input::DisplayCommand::Activity(input::ActivityCommand::CollapseAll {
visible_rows: 10,
}),
&mut state,
&[],
);
assert_eq!(
state.visible_activity_ids(),
vec![
crate::output::ActivityId::new("root-a"),
crate::output::ActivityId::new("root-b"),
crate::output::ActivityId::new("root-c"),
]
);
assert_eq!(
state.selected_activity_id().as_ref().map(|id| id.as_str()),
Some("root-b")
);
}
#[test]
fn activity_click_clearing_selection_reports_a_redraw() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
prompt_editor: crate::tui::state::PromptEditor::new("abc", 3),
..Default::default()
};
state.start_prompt_selection_at(0);
state.update_prompt_selection_at(2);
assert_eq!(state.prompt_selection_text().as_deref(), Some("ab"));
let result = apply_display_command(
input::DisplayCommand::Mouse(input::MouseCommand::ClickActivityTree {
row: u16::MAX,
visible_rows: 1,
}),
&mut state,
&[],
);
assert!(result.changed);
assert!(state.prompt_selection_text().is_none());
assert!(state.is_activity_tree_focused());
}
#[test]
fn display_reducer_applies_transcript_scroll_command() {
let mut state = state::MissionControlState::default();
for index in 0..20 {
state
.transcript
.push_back(format!("transcript line {index}"));
}
let result = apply_display_command(
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Transcript,
down: false,
rows: 2,
visible_rows: 4,
wrap_width: 40,
}),
&mut state,
&[],
);
assert!(result.changed);
assert_eq!(state.transcript_scroll_offset(), 2);
}
#[test]
fn standalone_ctrl_press_suppresses_its_release() {
let mut state = state::MissionControlState::default();
let now = Instant::now();
apply_standalone_ctrl(KeyEventKind::Press, now, &mut state);
assert!(state.last_standalone_ctrl.is_some());
apply_standalone_ctrl(KeyEventKind::Release, now, &mut state);
assert!(state.last_standalone_ctrl.is_some());
}
#[test]
fn right_column_tab_focus_command_reports_state_changes() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::ActivityDetail,
..Default::default()
};
let result = apply_display_command(
input::DisplayCommand::Focus(input::FocusCommand::RightColumnTab(
state::RightColumnTab::Summary,
)),
&mut state,
&[],
);
assert!(result.changed);
assert_eq!(state.right_column_tab, state::RightColumnTab::Summary);
assert!(state.is_prompt_focused());
let result = apply_display_command(
input::DisplayCommand::Focus(input::FocusCommand::RightColumnTab(
state::RightColumnTab::Activity,
)),
&mut state,
&[],
);
assert!(result.changed);
assert_eq!(state.right_column_tab, state::RightColumnTab::Activity);
assert!(state.is_prompt_focused());
let result = apply_display_command(
input::DisplayCommand::Focus(input::FocusCommand::RightColumnTab(
state::RightColumnTab::Activity,
)),
&mut state,
&[],
);
assert!(!result.changed);
}
}