use super::*;
use crate::output::{ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus};
use std::collections::HashSet;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn key_with_kind(code: KeyCode, kind: crossterm::event::KeyEventKind) -> KeyEvent {
KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind)
}
fn modified_key_with_kind(
code: KeyCode,
modifiers: KeyModifiers,
kind: crossterm::event::KeyEventKind,
) -> KeyEvent {
KeyEvent::new_with_kind(code, modifiers, kind)
}
fn state_with_activity() -> MissionControlState {
let mut state = MissionControlState::default();
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new("running"),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("running"),
});
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new("failed"),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Failed,
metadata: ActivityMetadata::new("failed"),
});
state.selected = 0;
state
}
fn state_with_parent_and_child() -> MissionControlState {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
..Default::default()
};
let parent = ActivityId::new("parent");
state.apply_activity_event(ActivityEvent::Started {
id: parent.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("parent activity"),
});
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new("child"),
parent_id: Some(parent),
kind: ActivityKind::Tool,
status: ActivityStatus::Success,
metadata: ActivityMetadata::new("child activity"),
});
state.selected = 0;
state
}
#[test]
fn typed_chars_enter_prompt_by_default_and_enter_submits() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
handle_key(key(KeyCode::Char('h')), &mut state),
InputAction::None
));
assert_eq!(state.input, "h");
assert!(matches!(
handle_key(key(KeyCode::Char('f')), &mut state),
InputAction::None
));
assert_eq!(state.input, "hf");
match handle_key(key(KeyCode::Enter), &mut state) {
InputAction::Submit(prompt) => assert_eq!(prompt, "hf"),
_ => panic!("expected submit"),
}
}
#[test]
fn key_release_events_do_not_edit_prompt() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
input: "ab".to_string(),
prompt_cursor: 2,
..Default::default()
};
assert!(matches!(
handle_key(
key_with_kind(KeyCode::Char('/'), crossterm::event::KeyEventKind::Release),
&mut state
),
InputAction::None
));
assert_eq!(state.input, "ab");
assert_eq!(state.prompt_cursor, 2);
assert!(matches!(
handle_key(
key_with_kind(KeyCode::Backspace, crossterm::event::KeyEventKind::Release),
&mut state
),
InputAction::None
));
assert_eq!(state.input, "ab");
assert_eq!(state.prompt_cursor, 2);
}
#[test]
fn question_mark_is_prompt_text_not_help() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
handle_key(key(KeyCode::Char('?')), &mut state),
InputAction::None
));
assert_eq!(state.input, "?");
assert!(!state.show_help);
}
#[test]
fn detail_keyboard_scroll_clamps_to_body_viewport_rows_after_sticky_summary() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = MissionControlState::default();
state.focus_activity_detail();
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new("tool-1"),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("long detail"),
});
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("tool-1"),
preview: (0..40)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
let viewports = crate::tui::viewports::key_viewports(area, &state);
let detail_viewport = viewports.detail.expect("detail viewport");
let detail_area = crate::tui::render::layout_for(area, &state)
.pane_area(crate::tui::render::TuiPane::Detail)
.expect("detail pane");
let text_area = crate::tui::render::content_text_area_for_pane(
crate::tui::render::TuiPane::Detail,
detail_area,
);
assert!(detail_viewport.visible_rows < text_area.height);
let old_full_pane_overflow = state.detail_overflow(text_area.height, text_area.width.max(1));
let body_overflow =
state.detail_overflow(detail_viewport.visible_rows, detail_viewport.wrap_width);
assert!(body_overflow > old_full_pane_overflow);
for _ in 0..100 {
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
viewports,
&[],
Instant::now()
),
InputAction::None
);
}
assert_eq!(state.detail_offset(), body_overflow);
}
#[test]
fn shifted_printable_prompt_characters_insert_shifted_text() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
for (input, expected) in [
('a', 'A'),
('z', 'Z'),
('1', '!'),
('/', '?'),
('-', '_'),
('=', '+'),
('A', 'A'),
('!', '!'),
('é', 'é'),
('🙂', '🙂'),
] {
assert_eq!(
handle_key(
modified_key(KeyCode::Char(input), KeyModifiers::SHIFT),
&mut state
),
InputAction::None
);
assert_eq!(state.input.pop(), Some(expected));
}
assert!(state.input.is_empty());
}
#[test]
fn shift_alt_and_shift_control_printable_keys_do_not_insert_prompt_text() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert_eq!(
handle_key(
modified_key(KeyCode::Char('a'), KeyModifiers::SHIFT | KeyModifiers::ALT),
&mut state,
),
InputAction::None
);
assert_eq!(
handle_key(
modified_key(
KeyCode::Char('a'),
KeyModifiers::SHIFT | KeyModifiers::CONTROL
),
&mut state,
),
InputAction::None
);
assert!(state.input.is_empty());
}
#[test]
fn unmodified_command_letters_are_prompt_text_not_actions() {
let mut state = state_with_activity();
state.focus_prompt();
for ch in ['h', 'l', 'e', 'c', 'f', 'r', '?'] {
assert!(matches!(
handle_key(key(KeyCode::Char(ch)), &mut state),
InputAction::None
));
}
assert_eq!(state.input, "hlecfr?");
assert_eq!(state.selected, 0);
assert!(!state.show_help);
}
#[test]
fn f1_toggles_help_and_quit_submit_still_exits() {
let mut state = MissionControlState {
..Default::default()
};
assert!(matches!(
handle_key(key(KeyCode::F(1)), &mut state),
InputAction::None
));
assert!(state.show_help);
assert_eq!(state.help_offset(), 0);
let mut state = MissionControlState {
input: "/quit".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::Exit
));
}
#[test]
fn modifier_bindings_trigger_activity_actions() {
let mut state = state_with_activity();
state.focus_activity_tree();
assert_eq!(state.selected, 0);
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('j'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.selected, 1);
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('k'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.selected, 0);
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('f'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.selected, 1);
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('r'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.selected, 0);
}
#[test]
fn alt_number_focuses_panes_without_side_effects() {
let mut state = state_with_activity();
state.focus_prompt();
state.input = "draft".to_string();
state.prompt_cursor = 3;
state.set_scroll_offset(&state.scroll_views.transcript, 4);
state.set_scroll_offset(&state.scroll_views.activity_tree, 5);
state.set_scroll_offset(&state.scroll_views.detail, 6);
let selected = state.selected;
assert_eq!(
handle_key(
modified_key(KeyCode::Char('1'), KeyModifiers::ALT),
&mut state
),
InputAction::None
);
assert!(state.is_activity_tree_focused());
assert_eq!(state.input, "draft");
assert_eq!(state.prompt_cursor, 3);
assert_eq!(state.selected, selected);
assert_eq!(state.transcript_scroll_offset(), 4);
assert_eq!(state.detail_offset(), 6);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('2'), KeyModifiers::ALT),
&mut state
),
InputAction::None
);
assert!(state.is_activity_detail_focused());
assert_eq!(state.selected, selected);
assert_eq!(state.activity_scroll_offset(), 5);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('3'), KeyModifiers::ALT),
&mut state
),
InputAction::None
);
assert!(state.is_activity_detail_focused());
assert_eq!(state.input, "draft");
}
#[test]
fn double_ctrl_accepts_crossterm_standalone_left_and_right_ctrl_modifier_keys() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let left_ctrl = modified_key(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::CONTROL,
);
let right_ctrl = modified_key(
KeyCode::Modifier(ModifierKeyCode::RightControl),
KeyModifiers::CONTROL,
);
assert_eq!(
handle_key_at(left_ctrl, &mut state, KeyViewports::default(), &[], first),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
assert_eq!(
handle_key_at(
right_ctrl,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn double_ctrl_accepts_modifier_ctrl_keys_without_ctrl_modifier_flag() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let left_ctrl = key(KeyCode::Modifier(ModifierKeyCode::LeftControl));
let right_ctrl = key(KeyCode::Modifier(ModifierKeyCode::RightControl));
assert_eq!(
handle_key_at(left_ctrl, &mut state, KeyViewports::default(), &[], first),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
assert_eq!(
handle_key_at(
right_ctrl,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn standalone_alt_no_longer_arms_prompt_focus_shortcut() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let standalone_alt = modified_key(
KeyCode::Modifier(ModifierKeyCode::LeftAlt),
KeyModifiers::ALT,
);
assert_eq!(
handle_key_at(
standalone_alt,
&mut state,
KeyViewports::default(),
&[],
first,
),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_none());
assert_eq!(
handle_key_at(
standalone_alt,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn plain_escape_does_not_arm_double_ctrl() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
assert_eq!(
handle_key_at(
key(KeyCode::Esc),
&mut state,
KeyViewports::default(),
&[],
first,
),
InputAction::None
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn alt_p_focuses_prompt_clears_help_and_then_accepts_typing() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
show_help: true,
prompt_cursor_visible: false,
..Default::default()
};
let alt_p = modified_key(KeyCode::Char('p'), KeyModifiers::ALT);
assert_eq!(handle_key(alt_p, &mut state), InputAction::None);
assert!(state.is_prompt_focused());
assert!(!state.show_help);
assert!(state.prompt_cursor_visible);
assert!(state.last_standalone_ctrl.is_none());
assert_eq!(
handle_key(key(KeyCode::Char('x')), &mut state),
InputAction::None
);
assert_eq!(state.input, "x");
}
#[test]
fn ctrl_p_does_not_focus_prompt() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
show_help: true,
prompt_cursor_visible: false,
..Default::default()
};
let ctrl_p = modified_key(KeyCode::Char('p'), KeyModifiers::CONTROL);
assert_eq!(handle_key(ctrl_p, &mut state), InputAction::None);
assert!(state.is_activity_tree_focused());
assert!(state.show_help);
assert!(!state.prompt_cursor_visible);
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn double_ctrl_focuses_prompt_within_timing_window() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let standalone_ctrl = modified_key(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::CONTROL,
);
assert_eq!(
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first
),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
assert_eq!(
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
assert_eq!(
handle_key_at(
key(KeyCode::Char('x')),
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(300),
),
InputAction::None
);
assert_eq!(state.input, "x");
}
#[test]
fn double_ctrl_accepts_release_only_modifier_ctrl_events() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let standalone_ctrl_release = modified_key_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Release,
);
assert_eq!(
handle_key_at(
standalone_ctrl_release,
&mut state,
KeyViewports::default(),
&[],
first,
),
InputAction::None
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
assert_eq!(
handle_key_at(
standalone_ctrl_release,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn single_ctrl_press_release_does_not_focus_prompt() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let ctrl_press = modified_key_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::CONTROL,
crossterm::event::KeyEventKind::Press,
);
let ctrl_release = modified_key_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Release,
);
handle_key_at(ctrl_press, &mut state, KeyViewports::default(), &[], first);
handle_key_at(
ctrl_release,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(20),
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
}
#[test]
fn ctrl_shortcut_release_does_not_arm_double_ctrl() {
let mut state = MissionControlState {
input: "abc".to_string(),
..Default::default()
};
let first = Instant::now();
let ctrl_c = modified_key_with_kind(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
crossterm::event::KeyEventKind::Press,
);
let ctrl_release = modified_key_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Release,
);
assert_eq!(
handle_key_at(ctrl_c, &mut state, KeyViewports::default(), &[], first),
InputAction::None
);
assert!(state.input.is_empty());
assert_eq!(
handle_key_at(
ctrl_release,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(20),
),
InputAction::None
);
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn double_ctrl_focuses_prompt_with_modal_active() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
state.open_system_prompt_modal("modal content".to_string());
let first = Instant::now();
let standalone_ctrl = modified_key(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::CONTROL,
);
assert_eq!(
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first
),
InputAction::None
);
assert!(state.system_prompt_modal_visible());
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
assert_eq!(
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(250),
),
InputAction::None
);
assert!(state.system_prompt_modal_visible());
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn double_ctrl_times_out_and_does_not_hijack_escape() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Transcript,
..Default::default()
};
let first = Instant::now();
let standalone_ctrl = modified_key(
KeyCode::Modifier(ModifierKeyCode::LeftControl),
KeyModifiers::CONTROL,
);
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first,
);
handle_key_at(
standalone_ctrl,
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(600),
);
assert!(state.is_transcript_focused());
assert!(state.last_standalone_ctrl.is_some());
handle_key_at(
key(KeyCode::Esc),
&mut state,
KeyViewports::default(),
&[],
first + Duration::from_millis(650),
);
assert!(state.is_prompt_focused());
assert!(state.last_standalone_ctrl.is_none());
}
#[test]
fn focus_bindings_do_not_require_printable_hotkeys() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL),
&mut state
),
InputAction::None
));
assert!(state.is_activity_tree_focused());
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(state.is_prompt_focused());
}
#[test]
fn ctrl_c_clears_input_then_exits_when_empty() {
let mut state = MissionControlState {
input: "abc".to_string(),
..Default::default()
};
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(matches!(handle_key(ctrl_c, &mut state), InputAction::None));
assert!(state.input.is_empty());
assert!(matches!(handle_key(ctrl_c, &mut state), InputAction::Exit));
}
#[test]
fn active_login_exposes_copy_and_open_shortcuts_without_staging_fallback() {
let mut state = MissionControlState::default();
state.start_active_login("openai-codex".to_string());
state.set_login_instructions("https://example.test/auth".to_string(), "open".to_string());
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state
),
InputAction::CopyLoginUrl
));
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('o'), KeyModifiers::CONTROL),
&mut state
),
InputAction::OpenLoginUrl
));
assert_eq!(
state.modals.active_login.as_ref().unwrap().fallback_input,
""
);
}
#[test]
fn active_login_plain_characters_still_stage_manual_fallback() {
let mut state = MissionControlState::default();
state.start_active_login("openai-codex".to_string());
assert!(matches!(
handle_key(key(KeyCode::Char('c')), &mut state),
InputAction::None
));
assert_eq!(
state.modals.active_login.as_ref().unwrap().fallback_input,
"c"
);
}
#[test]
fn shifted_printable_modal_text_entry_inserts_shifted_text() {
let mut custom = MissionControlState {
input: "draft".to_string(),
..Default::default()
};
custom.start_custom_provider_setup();
assert_eq!(
handle_key(
modified_key(KeyCode::Char('a'), KeyModifiers::SHIFT),
&mut custom,
),
InputAction::None
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('1'), KeyModifiers::SHIFT),
&mut custom,
),
InputAction::None
);
assert_eq!(
custom.modals.custom_provider_setup.as_ref().unwrap().label,
"A!"
);
assert_eq!(custom.input, "draft");
let mut login = MissionControlState::default();
login.start_active_login("openai-codex".to_string());
assert_eq!(
handle_key(
modified_key(KeyCode::Char('a'), KeyModifiers::SHIFT),
&mut login,
),
InputAction::None
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('1'), KeyModifiers::SHIFT),
&mut login,
),
InputAction::None
);
assert_eq!(
login.modals.active_login.as_ref().unwrap().fallback_input,
"A!"
);
}
#[test]
fn shift_alt_and_shift_control_modal_text_keys_do_not_insert_text() {
let mut custom = MissionControlState::default();
custom.start_custom_provider_setup();
assert_eq!(
handle_key(
modified_key(KeyCode::Char('a'), KeyModifiers::SHIFT | KeyModifiers::ALT),
&mut custom,
),
InputAction::None
);
assert_eq!(
handle_key(
modified_key(
KeyCode::Char('a'),
KeyModifiers::SHIFT | KeyModifiers::CONTROL
),
&mut custom,
),
InputAction::None
);
assert_eq!(
custom.modals.custom_provider_setup.as_ref().unwrap().label,
""
);
let mut login = MissionControlState::default();
login.start_active_login("openai-codex".to_string());
assert_eq!(
handle_key(
modified_key(KeyCode::Char('a'), KeyModifiers::SHIFT | KeyModifiers::ALT),
&mut login,
),
InputAction::None
);
assert_eq!(
handle_key(
modified_key(
KeyCode::Char('a'),
KeyModifiers::SHIFT | KeyModifiers::CONTROL
),
&mut login,
),
InputAction::None
);
assert_eq!(
login.modals.active_login.as_ref().unwrap().fallback_input,
""
);
}
#[test]
fn keybindings_have_no_duplicate_keys_in_same_context() {
let mut seen = HashSet::new();
for binding in default_keybindings() {
assert!(
seen.insert((binding.context, binding.key)),
"duplicate keybinding in {:?}: {}",
binding.context,
binding.label
);
}
}
#[test]
fn alt_up_down_keybindings_are_context_specific() {
assert_eq!(
action_for_key(
modified_key(KeyCode::Up, KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::ScrollTranscriptUp)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Down, KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::ScrollTranscriptDown)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Up, KeyModifiers::ALT),
KeyContext::ActivityDetail
),
Some(TuiAction::ScrollDetailUp)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Down, KeyModifiers::ALT),
KeyContext::ActivityDetail
),
Some(TuiAction::ScrollDetailDown)
);
}
#[test]
fn ctrl_shift_left_right_keybindings_are_global_layout_shortcuts() {
assert_eq!(
action_for_key(
modified_key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
KeyContext::Prompt
),
Some(TuiAction::FocusActivityLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
KeyContext::Prompt
),
Some(TuiAction::FocusTranscriptLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
KeyContext::ActivityTree
),
Some(TuiAction::FocusActivityLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
KeyContext::ActivityTree
),
Some(TuiAction::FocusTranscriptLayout)
);
}
#[test]
fn alt_left_right_keybindings_remain_global_layout_aliases() {
assert_eq!(
action_for_key(
modified_key(KeyCode::Right, KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::FocusActivityLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Left, KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::FocusTranscriptLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Right, KeyModifiers::ALT),
KeyContext::ActivityTree
),
Some(TuiAction::FocusActivityLayout)
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Left, KeyModifiers::ALT),
KeyContext::ActivityTree
),
Some(TuiAction::FocusTranscriptLayout)
);
}
#[test]
fn ctrl_shift_left_right_switch_layout_without_changing_prompt_activity_or_scroll_state() {
let candidates = autocomplete_candidates();
let mut state = state_with_activity();
state.focus_prompt();
state.input = "/q".to_string();
state.prompt_cursor = 2;
state.set_scroll_offset(&state.scroll_views.prompt, 3);
state.prompt_target_column = Some(9);
state.set_scroll_offset(&state.scroll_views.transcript, 4);
state.set_scroll_offset(&state.scroll_views.activity_tree, 5);
state.set_scroll_offset(&state.scroll_views.detail, 6);
state.recompute_autocomplete(&candidates);
let autocomplete_before = state.autocomplete.clone();
assert_eq!(
handle_key(
modified_key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
&mut state
),
InputAction::None
);
assert_eq!(
state.layout_mode,
super::super::state::TuiLayoutMode::ActivityFocused
);
assert_eq!(
handle_key(
modified_key(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
&mut state
),
InputAction::None
);
assert_eq!(
state.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
assert_eq!(state.input, "/q");
assert_eq!(state.prompt_cursor, 2);
assert_eq!(state.prompt_offset(), 3);
assert_eq!(state.prompt_target_column, Some(9));
assert_eq!(state.transcript_scroll_offset(), 4);
assert_eq!(state.activity_scroll_offset(), 5);
assert_eq!(state.detail_offset(), 6);
assert_eq!(state.selected, 0);
assert!(state.is_prompt_focused());
assert_eq!(state.autocomplete, autocomplete_before);
}
#[test]
fn active_modals_ignore_layout_shortcuts() {
let modal_key = modified_key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
let mut session = MissionControlState::default();
session.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
}],
1,
);
handle_key(modal_key, &mut session);
assert_eq!(
session.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut skills = MissionControlState::default();
skills.open_skills_modal(
vec![crate::tui::state::SkillToggleRow {
name: "skill-a".into(),
source: "test".to_string(),
enabled: true,
}],
1,
);
handle_key(modal_key, &mut skills);
assert_eq!(
skills.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut subagents = MissionControlState::default();
subagents.open_subagents_modal(
vec![crate::tui::state::SubagentProfileToggleRow {
id: "agent-a".into(),
description: "Agent A".into(),
enabled: true,
}],
1,
);
handle_key(modal_key, &mut subagents);
assert_eq!(
subagents.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut mcp = MissionControlState::default();
mcp.open_mcp_modal(
vec![crate::tui::state::McpServerToggleRow {
name: "server-a".into(),
kind: "stdio",
enabled: true,
}],
1,
);
handle_key(modal_key, &mut mcp);
assert_eq!(
mcp.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut system = MissionControlState::default();
system.open_system_prompt_modal("modal".into());
handle_key(modal_key, &mut system);
assert_eq!(
system.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut login = MissionControlState::default();
login.open_login_picker(
vec![super::super::state::LoginProviderEntry {
id: "openai-codex".into(),
label: "OpenAI Codex".into(),
description: String::new(),
status: String::new(),
}],
None,
1,
);
handle_key(modal_key, &mut login);
assert_eq!(
login.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut active_login = MissionControlState::default();
active_login.start_active_login("openai-codex".into());
handle_key(modal_key, &mut active_login);
assert_eq!(
active_login.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
let mut model = MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-a".to_string(),
..Default::default()
};
model.open_model_picker(
vec![crate::model_catalog::ModelCatalogEntry::new_codex("gpt-a")],
std::collections::BTreeSet::new(),
None,
1,
);
handle_key(modal_key, &mut model);
assert_eq!(
model.layout_mode,
super::super::state::TuiLayoutMode::TranscriptFocused
);
}
#[test]
fn mcp_modal_enter_toggles_selected_server() {
let mut state = MissionControlState::default();
state.open_mcp_modal(
vec![
crate::tui::state::McpServerToggleRow {
name: "server-a".into(),
kind: "stdio",
enabled: true,
},
crate::tui::state::McpServerToggleRow {
name: "server-b".into(),
kind: "http",
enabled: false,
},
],
2,
);
assert_eq!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
);
assert_eq!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::SetMcpServerEnabled {
name: "server-b".into(),
enabled: true
}
);
}
#[test]
fn active_modals_ignore_pane_focus_shortcuts() {
let pane_focus_key = modified_key(KeyCode::Char('1'), KeyModifiers::ALT);
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
}],
1,
);
handle_key(pane_focus_key, &mut state);
assert!(state.is_prompt_focused());
assert!(state.session_picker_visible());
}
#[test]
fn left_and_right_collapse_and_expand_selected_activity() {
let mut state = state_with_parent_and_child();
assert_eq!(state.visible_nodes().len(), 2);
assert_eq!(state.selected, 0);
assert!(matches!(
handle_key(key(KeyCode::Left), &mut state),
InputAction::None
));
assert_eq!(state.visible_nodes().len(), 1);
assert_eq!(state.selected, 0);
assert_eq!(state.selected_activity_id().unwrap().as_str(), "parent");
assert!(matches!(
handle_key(key(KeyCode::Right), &mut state),
InputAction::None
));
assert_eq!(state.visible_nodes().len(), 2);
assert_eq!(state.selected, 0);
assert_eq!(state.selected_activity_id().unwrap().as_str(), "parent");
}
#[test]
fn left_right_on_leaf_are_no_ops() {
let mut state = state_with_parent_and_child();
state.selected = 1;
let child = state.selected_activity_id().unwrap();
let was_expanded = state.expanded.clone();
assert!(matches!(
handle_key(key(KeyCode::Left), &mut state),
InputAction::None
));
assert_eq!(state.selected, 1);
assert_eq!(state.selected_activity_id().unwrap(), child);
assert_eq!(state.expanded, was_expanded);
assert!(matches!(
handle_key(key(KeyCode::Right), &mut state),
InputAction::None
));
assert_eq!(state.selected, 1);
assert_eq!(state.selected_activity_id().unwrap(), child);
assert_eq!(state.expanded, was_expanded);
}
#[test]
fn alt_up_down_scroll_detail_when_detail_focused() {
let mut state = state_with_activity();
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("running"),
preview: "detail body line 1\ndetail body line 2\ndetail body line 3".to_string(),
});
state.focus_activity_detail();
assert!(matches!(
handle_key(modified_key(KeyCode::Down, KeyModifiers::ALT), &mut state),
InputAction::None
));
assert_eq!(state.detail_offset(), 1);
assert!(matches!(
handle_key(modified_key(KeyCode::Up, KeyModifiers::ALT), &mut state),
InputAction::None
));
assert_eq!(state.detail_offset(), 0);
}
#[test]
fn changing_activity_selection_resets_detail() {
let mut state = state_with_activity();
state.focus_activity_tree();
state.scroll_detail_down();
assert_eq!(state.detail_offset(), 1);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
assert_eq!(state.selected, 1);
assert_eq!(state.detail_offset(), 0);
state.scroll_detail_down();
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('r'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.selected, 0);
assert_eq!(state.detail_offset(), 0);
}
#[test]
fn collapse_all_clamps_child_selection_and_preserves_valid_detail() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
input: "draft stays".to_string(),
prompt_cursor: "draft stays".len(),
transcript: vec!["you: existing".to_string()].into(),
..Default::default()
};
let parent = ActivityId::new("parent");
let child = ActivityId::new("child");
state.apply_activity_event(ActivityEvent::Started {
id: parent.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("parent activity"),
});
state.apply_activity_event(ActivityEvent::Started {
id: child,
parent_id: Some(parent),
kind: ActivityKind::Tool,
status: ActivityStatus::Success,
metadata: ActivityMetadata::new("child activity"),
});
assert_eq!(state.visible_nodes().len(), 2);
state.selected = 1;
state.scroll_detail_down();
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('c'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.visible_nodes().len(), 1);
assert_eq!(state.input, "draft stays");
assert_eq!(state.prompt_cursor, "draft stays".len());
assert_eq!(state.transcript, vec!["you: existing".to_string()]);
assert_eq!(state.selected, 0);
assert_eq!(state.detail_offset(), 0);
let detail = state.selected_activity_text();
assert!(detail.contains("id: parent"));
assert!(detail.contains("label: parent activity"));
assert!(!detail.contains("No activity selected"));
}
#[test]
fn prompt_arrows_move_cursor_not_activity_selection_when_prompt_focused() {
let mut state = state_with_activity();
state.focus_prompt();
state.input = "abcd".to_string();
state.prompt_cursor = 2;
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Left), &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.prompt_cursor, 1);
assert_eq!(state.selected, 0);
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Right), &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.prompt_cursor, 2);
}
#[test]
fn activity_arrows_still_control_activity_when_activity_focused() {
let mut state = state_with_activity();
state.focus_activity_tree();
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Down), &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.selected, 1);
assert_eq!(state.prompt_cursor, 0);
}
#[test]
fn keyboard_activity_selection_uses_activity_viewport_not_transcript_viewport() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
..Default::default()
};
for index in 0..10 {
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(format!("tool {index}")),
});
}
state.selected = 2;
state.set_scroll_offset(&state.scroll_views.activity_tree, 0);
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
KeyViewports {
transcript: Some(TranscriptViewport {
visible_rows: 10,
wrap_width: 80,
}),
activity_tree: Some(ActivityTreeViewport { visible_rows: 3 }),
..KeyViewports::default()
},
&[],
Instant::now(),
),
InputAction::None
);
assert_eq!(state.selected, 3);
assert_eq!(state.activity_tree_render_scroll(3), 1);
}
#[test]
fn keyboard_activity_selection_after_viewport_scroll_reveals_selected_row() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
..Default::default()
};
for index in 0..10 {
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(format!("tool {index}")),
});
}
state.selected = 0;
state.set_scroll_offset(&state.scroll_views.activity_tree, 5);
assert_eq!(state.activity_tree_render_scroll(3), 5);
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
KeyViewports {
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
..KeyViewports::default()
},
&[],
Instant::now(),
),
InputAction::None
);
assert_eq!(state.selected, 1);
assert_eq!(state.activity_tree_render_scroll(3), 1);
}
#[test]
fn shift_tab_still_cycles_primary_agent_not_right_tab() {
let mut state = MissionControlState::default();
state.set_primary_agents(vec![crate::tui::state::PrimaryAgentEntry {
id: "tars".to_string(),
name: "TARS".to_string(),
description: "tactical unit".to_string(),
prompt: "stay alive".to_string(),
}]);
assert_eq!(
handle_key(
modified_key(KeyCode::BackTab, KeyModifiers::SHIFT),
&mut state
),
InputAction::PrimaryAgentSelectionChanged(Some("tars".to_string()))
);
}
#[test]
fn prompt_typing_backspace_delete_edit_at_cursor() {
let mut state = MissionControlState {
input: "ac🙂".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: 1,
..Default::default()
};
let viewport = Some(PromptViewport {
visible_rows: 2,
wrap_width: 10,
});
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Char('é')), &mut state, viewport, &[]),
InputAction::None
));
assert_eq!(state.input, "aéc🙂");
assert_eq!(state.prompt_cursor, "aé".len());
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Delete), &mut state, viewport, &[]),
InputAction::None
));
assert_eq!(state.input, "aé🙂");
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Backspace), &mut state, viewport, &[]),
InputAction::None
));
assert_eq!(state.input, "a🙂");
}
#[test]
fn system_prompt_modal_alt_arrows_scroll_modal_and_suppress_underlying_state() {
let mut state = state_with_activity();
state.focus_prompt();
state.input = "line one\nline two".to_string();
state.prompt_cursor = state.input.len();
state.set_scroll_offset(&state.scroll_views.prompt, 1);
state.set_scroll_offset(&state.scroll_views.transcript, 2);
state.set_scroll_offset(&state.scroll_views.activity_tree, 3);
state.set_scroll_offset(&state.scroll_views.detail, 4);
state.open_system_prompt_modal(
(0..12)
.map(|line| format!("modal line {line}"))
.collect::<Vec<_>>()
.join("\n"),
);
let viewport = Some(SystemPromptViewport {
visible_rows: 3,
wrap_width: 80,
});
assert!(matches!(
handle_key_with_all_viewports(
modified_key(KeyCode::Down, KeyModifiers::ALT),
&mut state,
KeyViewports {
system_prompt: viewport,
..KeyViewports::default()
},
&[]
),
InputAction::None
));
assert_eq!(state.scroll_offset(&state.scroll_views.system_prompt), 1);
assert_eq!(state.prompt_cursor, "line one\nline two".len());
assert_eq!(state.prompt_offset(), 1);
assert_eq!(state.transcript_scroll_offset(), 2);
assert_eq!(state.activity_scroll_offset(), 3);
assert_eq!(state.detail_offset(), 4);
assert!(matches!(
handle_key_with_all_viewports(
modified_key(KeyCode::Up, KeyModifiers::ALT),
&mut state,
KeyViewports {
system_prompt: viewport,
..KeyViewports::default()
},
&[]
),
InputAction::None
));
assert_eq!(state.scroll_offset(&state.scroll_views.system_prompt), 0);
assert_eq!(state.prompt_cursor, "line one\nline two".len());
assert_eq!(state.prompt_offset(), 1);
assert_eq!(state.transcript_scroll_offset(), 2);
assert_eq!(state.activity_scroll_offset(), 3);
assert_eq!(state.detail_offset(), 4);
}
#[test]
fn transcript_focus_plain_up_down_scrolls_transcript_only() {
let mut state = state_with_activity();
state.focus_pane = crate::tui::state::TuiFocusPane::Transcript;
state.prompt_cursor_visible = false;
state.input = "alpha\nbeta\ngamma".to_string();
state.prompt_cursor = "alpha\nbeta".len();
state.set_scroll_offset(&state.scroll_views.detail, 2);
state.selected = 1;
for line in 0..10 {
state
.transcript
.push_back(format!("assistant: line {line}"));
}
let viewports = KeyViewports {
prompt: Some(PromptViewport {
visible_rows: 2,
wrap_width: 80,
}),
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
detail: Some(DetailViewport {
visible_rows: 2,
wrap_width: 80,
}),
..KeyViewports::default()
};
let selected = state.selected;
let prompt_cursor = state.prompt_cursor;
let detail_offset = state.detail_offset();
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Up), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.transcript_scroll_offset(), 1);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor, prompt_cursor);
assert_eq!(state.detail_offset(), detail_offset);
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Down), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor, prompt_cursor);
assert_eq!(state.detail_offset(), detail_offset);
}
#[test]
fn activity_detail_focus_plain_up_down_scrolls_detail_only() {
let mut state = state_with_activity();
state.focus_activity_detail();
state.input = "alpha\nbeta\ngamma".to_string();
state.prompt_cursor = "alpha\nbeta".len();
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.selected = 1;
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("failed"),
preview: "detail body line 1\ndetail body line 2\ndetail body line 3".to_string(),
});
let viewports = KeyViewports {
prompt: Some(PromptViewport {
visible_rows: 2,
wrap_width: 80,
}),
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
detail: Some(DetailViewport {
visible_rows: 1,
wrap_width: 8,
}),
..KeyViewports::default()
};
assert!(state.detail_overflow(1, 8) > 0);
let selected = state.selected;
let prompt_cursor = state.prompt_cursor;
let transcript_scroll_offset = state.transcript_scroll_offset();
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Down), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.detail_offset(), 1);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor, prompt_cursor);
assert_eq!(state.transcript_scroll_offset(), transcript_scroll_offset);
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Up), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor, prompt_cursor);
assert_eq!(state.transcript_scroll_offset(), transcript_scroll_offset);
}
#[test]
fn prompt_plain_arrows_move_cursor_without_scrolling_other_panes() {
let mut state = MissionControlState {
input: "alpha\nbeta\ngamma".to_string(),
prompt_cursor: "alpha\nbeta".len(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.set_scroll_offset(&state.scroll_views.transcript, 2);
state.set_scroll_offset(&state.scroll_views.detail, 3);
state.set_scroll_offset(&state.scroll_views.help, 4);
state.open_system_prompt_modal("modal".to_string());
state.close_system_prompt_modal();
let viewport = Some(PromptViewport {
visible_rows: 2,
wrap_width: 80,
});
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Up), &mut state, viewport, &[]),
InputAction::None
));
assert!(state.prompt_cursor < "alpha\nbeta".len());
assert_eq!(state.prompt_offset(), 0);
assert_eq!(state.transcript_scroll_offset(), 2);
assert_eq!(state.detail_offset(), 3);
assert_eq!(state.help_offset(), 4);
assert!(state.modals.system_prompt_modal.is_none());
}
#[test]
fn prompt_alt_up_down_scroll_transcript_not_prompt_or_detail_when_prompt_focused() {
let mut state = state_with_activity();
state.focus_prompt();
state.input = "x".repeat(30);
state.prompt_cursor = 0;
for line in 0..10 {
state.transcript.push_back(format!("user: line {line}"));
}
let prompt_viewport = Some(PromptViewport {
visible_rows: 2,
wrap_width: 5,
});
let transcript_viewport = Some(TranscriptViewport {
visible_rows: 4,
wrap_width: 80,
});
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Up, KeyModifiers::ALT),
&mut state,
prompt_viewport,
transcript_viewport,
&[]
),
InputAction::None
));
assert_eq!(state.transcript_scroll_offset(), 1);
assert_eq!(state.prompt_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert!(state.is_prompt_focused());
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Down, KeyModifiers::ALT),
&mut state,
prompt_viewport,
transcript_viewport,
&[]
),
InputAction::None
));
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.prompt_offset(), 0);
assert_eq!(state.detail_offset(), 0);
}
#[test]
fn prompt_alt_up_down_transcript_scroll_clamps_at_boundaries() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
for line in 0..6 {
state.transcript.push_back(format!("user: line {line}"));
}
let transcript_viewport = Some(TranscriptViewport {
visible_rows: 4,
wrap_width: 80,
});
let overflow = state.transcript_scroll_overflow(4, 80);
assert!(overflow > 0);
while state.transcript_scroll_offset() < overflow {
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Up, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&[]
),
InputAction::None
));
}
assert_eq!(state.transcript_scroll_offset(), overflow);
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Up, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&[]
),
InputAction::None
));
assert_eq!(state.transcript_scroll_offset(), overflow);
while state.transcript_scroll_offset() > 0 {
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Down, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&[]
),
InputAction::None
));
}
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Down, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&[]
),
InputAction::None
));
assert_eq!(state.transcript_scroll_offset(), 0);
}
#[test]
fn prompt_alt_up_down_preserve_prompt_text_cursor_focus_and_autocomplete() {
let candidates = autocomplete_candidates();
let mut state = MissionControlState {
input: "/q".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: 2,
prompt_target_column: Some(7),
..Default::default()
};
for line in 0..10 {
state.transcript.push_back(format!("user: line {line}"));
}
state.recompute_autocomplete(&candidates);
state.autocomplete.as_mut().unwrap().selected = 1;
state.autocomplete_dismissed_token = Some("/ignored".to_string());
let autocomplete_before = state.autocomplete.clone();
let dismissed_before = state.autocomplete_dismissed_token.clone();
let transcript_viewport = Some(TranscriptViewport {
visible_rows: 4,
wrap_width: 80,
});
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Up, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&candidates
),
InputAction::None
));
assert!(matches!(
handle_key_with_viewports(
modified_key(KeyCode::Down, KeyModifiers::ALT),
&mut state,
None,
transcript_viewport,
&candidates
),
InputAction::None
));
assert_eq!(state.input, "/q");
assert_eq!(state.prompt_cursor, 2);
assert_eq!(state.prompt_offset(), 0);
assert_eq!(state.prompt_target_column, Some(7));
assert!(state.is_prompt_focused());
assert_eq!(state.autocomplete, autocomplete_before);
assert_eq!(state.autocomplete_dismissed_token, dismissed_before);
}
#[test]
fn shift_enter_inserts_newline_at_cursor_and_plain_enter_submits_multiline_prompt() {
let mut state = MissionControlState {
input: "helloworld".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: "hello".len(),
..Default::default()
};
assert!(matches!(
handle_key_with_viewport(
modified_key(KeyCode::Enter, KeyModifiers::SHIFT),
&mut state,
None,
&[]
),
InputAction::None
));
assert_eq!(state.input, "hello\nworld");
assert_eq!(state.prompt_cursor, "hello\n".len());
match handle_key_with_viewport(key(KeyCode::Enter), &mut state, None, &[]) {
InputAction::Submit(prompt) => assert_eq!(prompt, "hello\nworld"),
_ => panic!("expected submit"),
}
}
#[test]
fn shifted_enter_with_extra_modifier_bits_inserts_newline_without_submitting() {
let mut state = MissionControlState {
input: "helloworld".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: "hello".len(),
..Default::default()
};
assert!(matches!(
handle_key_with_viewport(
modified_key(KeyCode::Enter, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
&mut state,
None,
&[]
),
InputAction::None
));
assert_eq!(state.input, "hello\nworld");
assert_eq!(state.prompt_cursor, "hello\n".len());
}
#[test]
fn prompt_paste_multiline_inserts_newlines_without_submitting() {
let mut state = MissionControlState {
input: "alphaomega".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: "alpha".len(),
..Default::default()
};
assert!(matches!(
handle_paste_with_viewport("\nbeta\ngamma", &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.input, "alpha\nbeta\ngammaomega");
assert_eq!(state.prompt_cursor, "alpha\nbeta\ngamma".len());
}
#[test]
fn submit_after_middle_insertion_returns_canonical_text_and_resets_editor_state() {
let mut state = MissionControlState {
input: "ac".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: 1,
..Default::default()
};
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Char('b')), &mut state, None, &[]),
InputAction::None
));
match handle_key_with_viewport(key(KeyCode::Enter), &mut state, None, &[]) {
InputAction::Submit(prompt) => assert_eq!(prompt, "abc"),
_ => panic!("expected submit"),
}
assert!(state.input.is_empty());
assert_eq!(state.prompt_cursor, 0);
assert_eq!(state.prompt_offset(), 0);
}
#[test]
fn quit_submits_as_exit_even_when_cursor_is_not_at_end() {
let mut state = MissionControlState {
input: "/quit".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: 1,
..Default::default()
};
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Enter), &mut state, None, &[]),
InputAction::Exit
));
}
#[test]
fn quit_with_trailing_args_submits_as_exit() {
let mut state = MissionControlState {
input: "/quit extra".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
prompt_cursor: "/quit extra".len(),
..Default::default()
};
assert!(matches!(
handle_key_with_viewports(key(KeyCode::Enter), &mut state, None, None, &[]),
InputAction::Exit
));
}
#[test]
fn logout_picker_escape_cancels_without_provider_selection() {
let mut state = MissionControlState::default();
state.open_logout_picker(
vec![super::super::state::LoginProviderEntry {
id: "openai-codex".to_string(),
label: "OpenAI Codex".to_string(),
description: "codex".to_string(),
status: "configured".to_string(),
}],
None,
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
let action = handle_key(key(KeyCode::Esc), &mut state);
assert!(matches!(action, InputAction::None));
assert!(state.modals.login_picker.is_none());
}
#[test]
fn logout_picker_up_down_navigation_scrolls_and_selects_logout_provider() {
let mut state = MissionControlState::default();
let entries = (0..12)
.map(|index| super::super::state::LoginProviderEntry {
id: format!("provider-{index}"),
label: format!("Provider {index}"),
description: "test provider".to_string(),
status: "configured".to_string(),
})
.collect::<Vec<_>>();
state.open_logout_picker(entries, None, DEFAULT_PICKER_VISIBLE_ROWS as usize);
for _ in 0..11 {
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
}
let picker = state.modals.login_picker.as_ref().unwrap();
assert_eq!(picker.selected, 11);
assert!(matches!(
handle_key(key(KeyCode::Up), &mut state),
InputAction::None
));
let picker = state.modals.login_picker.as_ref().unwrap();
assert_eq!(picker.selected, 10);
let action = handle_key(key(KeyCode::Enter), &mut state);
assert!(
matches!(action, InputAction::SelectLogoutProvider(provider) if provider == "provider-10")
);
assert!(state.modals.login_picker.is_none());
}
#[test]
fn login_picker_uses_actual_viewport_to_keep_selection_visible() {
let mut state = MissionControlState::default();
let entries = (0..6)
.map(|index| super::super::state::LoginProviderEntry {
id: format!("provider-{index}"),
label: format!("Provider {index}"),
description: "test provider".to_string(),
status: "available".to_string(),
})
.collect::<Vec<_>>();
state.open_login_picker(entries, None, 2);
for _ in 0..3 {
assert!(matches!(
handle_key_with_all_viewports(
key(KeyCode::Down),
&mut state,
KeyViewports {
login_picker: Some(PickerViewport { visible_rows: 2 }),
..KeyViewports::default()
},
&[]
),
InputAction::None
));
}
let picker = state.modals.login_picker.as_ref().unwrap();
assert_eq!(picker.selected, 3);
let offset = state.scroll_offset(&state.scroll_views.login_picker);
assert!(picker.selected >= offset);
assert!(picker.selected < offset + 2);
}
#[test]
fn logout_picker_uses_actual_viewport_to_keep_selection_visible() {
let mut state = MissionControlState::default();
let entries = (0..6)
.map(|index| super::super::state::LoginProviderEntry {
id: format!("provider-{index}"),
label: format!("Provider {index}"),
description: "test provider".to_string(),
status: "configured".to_string(),
})
.collect::<Vec<_>>();
state.open_logout_picker(entries, None, 2);
for _ in 0..3 {
assert!(matches!(
handle_key_with_all_viewports(
key(KeyCode::Down),
&mut state,
KeyViewports {
login_picker: Some(PickerViewport { visible_rows: 2 }),
..KeyViewports::default()
},
&[]
),
InputAction::None
));
}
let picker = state.modals.login_picker.as_ref().unwrap();
let offset = state.scroll_offset(&state.scroll_views.login_picker);
assert!(picker.selected >= offset);
assert!(picker.selected < offset + 2);
}
#[test]
fn alt_t_cycles_thinking_without_editing_prompt_or_conflicting_with_plain_t_or_shift_tab() {
let mut state = MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5".to_string(),
input: "draft".to_string(),
prompt_cursor: "draft".len(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
thinking_levels: crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec(),
..Default::default()
};
state.refresh_thinking_levels(
crate::thinking::ThinkingLevel::Default,
state.thinking_levels.clone(),
);
assert_eq!(
handle_key(key(KeyCode::Char('t')), &mut state),
InputAction::None
);
assert_eq!(state.input, "draftt");
assert_eq!(
state.thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('T'), KeyModifiers::SHIFT),
&mut state
),
InputAction::None
);
assert_eq!(state.input, "drafttT");
assert_eq!(
state.thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('t'), KeyModifiers::ALT),
&mut state
),
InputAction::ThinkingLevelSelectionChanged(crate::thinking::ThinkingLevel::Low)
);
assert_eq!(state.input, "drafttT");
assert_eq!(state.thinking_level, crate::thinking::ThinkingLevel::Low);
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Low
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Char('t'), KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::CycleThinkingLevel)
);
assert_eq!(
action_for_key(
modified_key(
KeyCode::Char('T'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
),
KeyContext::Prompt
),
None
);
assert_eq!(
action_for_key(
modified_key(KeyCode::Char('T'), KeyModifiers::SHIFT),
KeyContext::Prompt
),
None
);
assert_eq!(
action_for_key(
modified_key(KeyCode::BackTab, KeyModifiers::SHIFT),
KeyContext::Prompt
),
Some(TuiAction::CyclePrimaryAgent)
);
}
#[test]
fn alt_m_opens_set_model_picker() {
assert_eq!(
action_for_key(
modified_key(KeyCode::Char('m'), KeyModifiers::ALT),
KeyContext::Prompt
),
Some(TuiAction::OpenSetModel)
);
}
#[test]
fn alt_t_on_unsupported_model_reports_bounded_diagnostic() {
let mut state = MissionControlState {
provider: "local-ai".to_string(),
model: "gpt-5".to_string(),
input: "draft".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.refresh_thinking_levels(
crate::thinking::ThinkingLevel::High,
state.thinking_levels.clone(),
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('t'), KeyModifiers::ALT),
&mut state
),
InputAction::None
);
assert_eq!(state.input, "draft");
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert!(state.status.contains("not configurable"));
}
#[test]
fn alt_t_uses_cached_custom_provider_reasoning_support() {
let mut state = MissionControlState {
provider: "local-ai".to_string(),
model: "gpt-5".to_string(),
input: "draft".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
thinking_levels: crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec(),
..Default::default()
};
state.refresh_thinking_levels(
crate::thinking::ThinkingLevel::Default,
state.thinking_levels.clone(),
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('t'), KeyModifiers::ALT),
&mut state
),
InputAction::ThinkingLevelSelectionChanged(crate::thinking::ThinkingLevel::Low)
);
assert_eq!(state.input, "draft");
assert_eq!(state.thinking_level, crate::thinking::ThinkingLevel::Low);
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Low
);
}
#[test]
fn alt_t_uses_cached_custom_provider_reasoning_unsupported_metadata() {
let mut state = MissionControlState {
provider: "local-ai".to_string(),
model: "gpt-5".to_string(),
input: "draft".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
thinking_levels: vec![crate::thinking::ThinkingLevel::Default],
..Default::default()
};
state.refresh_thinking_levels(
crate::thinking::ThinkingLevel::High,
state.thinking_levels.clone(),
);
assert_eq!(
handle_key(
modified_key(KeyCode::Char('t'), KeyModifiers::ALT),
&mut state
),
InputAction::None
);
assert_eq!(state.input, "draft");
assert_eq!(
state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert!(state.status.contains("not configurable"));
}
#[test]
fn help_text_is_generated_from_keymap_and_mentions_auth_commands() {
let help = help_text();
for binding in default_keybindings() {
assert!(help.contains(binding.label), "missing {}", binding.label);
assert!(
help.contains(binding.description),
"missing {}",
binding.description
);
}
assert!(help.contains("type normally"));
assert!(help.contains("arrows move the cursor"));
assert!(help.contains("[Alt-Up]/[Alt-Down] scroll transcript while Prompt is focused"));
assert!(help.contains("mouse wheel over Transcript also scrolls transcript"));
assert!(!help.contains("with Transcript focused"));
assert!(help.contains("[Ctrl-Shift-Right] uses activity-focused columns"));
assert!(help.contains("[Alt-Right]/[Alt-Left] remain secondary aliases"));
assert!(help.contains("Long prompt: mouse wheel over Prompt scrolls the editor."));
assert!(!help.contains("Alt-Up/Alt-Down or mouse wheel over Prompt"));
assert!(!help.contains("scroll prompt up"));
assert!(!help.contains("scroll prompt down"));
assert!(help.contains("mouse wheel over Help scrolls this text"));
assert!(help.contains("Mouse: wheel follows the hovered scrollable pane"));
assert!(help.contains("System prompt modal: [Esc]/[q] close; [Up]/[Down]/[Alt-Up]/[Alt-Down]/[PageUp]/[PageDown]/[Home]/[End] and mouse wheel scroll."));
assert!(help.contains("@ anywhere tags cwd files with fuzzy matching"));
assert!(help.contains("$ anywhere tags enabled skills with fuzzy matching"));
assert!(help.contains("/login openai-codex authenticates"));
assert!(help.contains("/logout openai-codex removes local auth"));
assert!(help.contains("/new starts a fresh session"));
assert!(help.contains("/quit exits"));
assert!(help.contains("[Alt-T] cycles thinking level"));
assert!(!help.contains("Ctrl-Shift-T"));
assert!(!help.contains("future work in TUI mode"));
}
fn autocomplete_candidates() -> Vec<AutocompleteCandidate> {
vec![
AutocompleteCandidate::slash_command("new", "start a new session"),
AutocompleteCandidate::slash_command("quit", "exit magi-code"),
]
}
fn handle_key_with_candidates(key: KeyEvent, state: &mut MissionControlState) -> InputAction {
let candidates = autocomplete_candidates();
handle_key_with_viewport(key, state, None, &candidates)
}
#[test]
fn autocomplete_tab_accepts_when_visible() {
let mut state = MissionControlState {
input: "/n".to_string(),
prompt_cursor: 2,
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.recompute_autocomplete(&autocomplete_candidates());
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Tab), &mut state),
InputAction::None
));
assert_eq!(state.input, "/new");
assert!(state.is_prompt_focused());
}
#[test]
fn autocomplete_enter_accepts_when_visible_without_submitting() {
let mut state = MissionControlState {
input: "/n".to_string(),
prompt_cursor: 2,
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.recompute_autocomplete(&autocomplete_candidates());
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Enter), &mut state),
InputAction::None
));
assert_eq!(state.input, "/new");
assert!(state.is_prompt_focused());
}
#[test]
fn autocomplete_arrows_navigate_when_visible_and_move_cursor_when_hidden() {
let mut state = MissionControlState {
input: "/q".to_string(),
prompt_cursor: 2,
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.recompute_autocomplete(&autocomplete_candidates());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Down), &mut state),
InputAction::None
));
assert_eq!(state.autocomplete.as_ref().unwrap().selected, 0);
assert_eq!(state.prompt_cursor, 2);
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Up), &mut state),
InputAction::None
));
assert_eq!(state.autocomplete.as_ref().unwrap().selected, 0);
state.hide_autocomplete();
state.input = "a\nb".to_string();
state.prompt_cursor = state.input.len();
assert!(matches!(
handle_key(key(KeyCode::Up), &mut state),
InputAction::None
));
assert!(state.prompt_cursor < state.input.len());
}
#[test]
fn autocomplete_esc_dismisses_before_focus_and_help_behavior() {
let mut state = MissionControlState {
input: "/n".to_string(),
prompt_cursor: 2,
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
show_help: true,
..Default::default()
};
state.recompute_autocomplete(&autocomplete_candidates());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.autocomplete_visible());
assert!(state.show_help);
assert!(state.is_prompt_focused());
state.focus_activity_tree();
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(state.is_prompt_focused());
assert!(!state.show_help);
}
#[test]
fn autocomplete_recomputes_and_hides_after_prompt_edits() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Char('/')), &mut state),
InputAction::None
));
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Char('n')), &mut state),
InputAction::None
));
assert_eq!(
state.autocomplete.as_ref().unwrap().candidates[0].name,
"new"
);
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Backspace), &mut state),
InputAction::None
));
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_candidates(key(KeyCode::Backspace), &mut state),
InputAction::None
));
assert!(state.autocomplete.is_none());
}
#[test]
fn file_autocomplete_tab_accepts_active_at_token_anywhere_without_toggling_focus() {
let candidates = vec![AutocompleteCandidate::file_tag("src/tui/input/mod.rs")];
let mut state = MissionControlState {
input: "check @tuiinp".to_string(),
prompt_cursor: "check @tuiinp".len(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.recompute_autocomplete(&candidates);
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Tab), &mut state, None, &candidates),
InputAction::None
));
assert_eq!(state.input, "check @src/tui/input/mod.rs ");
assert!(state.is_prompt_focused());
}
#[test]
fn skill_autocomplete_accepts_active_dollar_token_anywhere_without_submitting() {
let candidates = vec![AutocompleteCandidate::skill_tag("rust-dev")];
let mut state = MissionControlState {
input: "please use $rust".to_string(),
prompt_cursor: "please use $rust".len(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.recompute_autocomplete(&candidates);
assert!(state.autocomplete_visible());
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Enter), &mut state, None, &candidates),
InputAction::None
));
assert_eq!(state.input, "please use $rust-dev ");
assert!(state.is_prompt_focused());
}
#[test]
fn submitted_skill_tags_remain_literal_prompt_text() {
let mut state = MissionControlState {
input: "please use $rust-dev".to_string(),
prompt_cursor: "please use $rust-dev".len(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
match handle_key_with_viewport(key(KeyCode::Enter), &mut state, None, &[]) {
InputAction::Submit(prompt) => assert_eq!(prompt, "please use $rust-dev"),
other => panic!("expected submit, got {other:?}"),
}
assert!(state.autocomplete.is_none());
}
#[test]
fn model_picker_keys_take_priority_over_autocomplete_and_prompt() {
let mut state = MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-a".to_string(),
input: "/m".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state.autocomplete = Some(crate::tui::state::AutocompleteState {
candidates: vec![AutocompleteCandidate::slash_command("model", "select")],
selected: 0,
token_start: 0,
token_end: 2,
token: "/m".to_string(),
});
state.open_model_picker(
vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-a"),
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-b"),
],
std::collections::BTreeSet::new(),
None,
1,
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
assert!(matches!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::SelectModel(model) if model == "openai-codex/gpt-b"
));
assert!(state.modals.model_picker.is_none());
assert_eq!(state.input, "/m");
}
#[test]
fn model_picker_uses_actual_viewport_to_keep_selection_visible() {
let mut state = MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-0".to_string(),
..Default::default()
};
state.open_model_picker(
(0..6)
.map(|index| crate::model_catalog::ModelCatalogEntry::new_codex(format!("gpt-{index}")))
.collect(),
std::collections::BTreeSet::new(),
None,
2,
);
for _ in 0..3 {
assert!(matches!(
handle_key_with_all_viewports(
key(KeyCode::Down),
&mut state,
KeyViewports {
model_picker: Some(PickerViewport { visible_rows: 2 }),
..KeyViewports::default()
},
&[]
),
InputAction::None
));
}
let picker = state.modals.model_picker.as_ref().unwrap();
assert_eq!(picker.selected, 3);
assert_eq!(state.scroll_offset(&state.scroll_views.model_picker), 2);
}
#[test]
fn session_picker_keys_take_priority_over_prompt_and_autocomplete() {
let mut state = MissionControlState {
input: "keep".to_string(),
..Default::default()
};
state.open_session_picker(
vec![
crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
},
crate::tui::state::SessionPickerRow {
id: "session-b".into(),
label: "session-b".into(),
is_current: false,
preview: None,
},
],
1,
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
assert_eq!(state.input, "keep");
assert_eq!(state.selected_session_id().as_deref(), Some("session-b"));
}
#[test]
fn session_picker_tab_focus_and_preview_arrows_do_not_move_list() {
let mut state = MissionControlState::default();
state.open_session_picker(
vec![
crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
},
crate::tui::state::SessionPickerRow {
id: "session-b".into(),
label: "session-b".into(),
is_current: false,
preview: None,
},
],
2,
);
state.set_session_preview(
"session-a",
crate::tui::state::SessionPreviewState::Ready(crate::tui::state::SessionPreview {
first: crate::tui::state::PreviewMessage {
role: "user",
text: "one two three four five six seven eight nine ten".to_string(),
},
most_recent: None,
}),
);
let viewports = KeyViewports {
sessions_modal: Some(SessionPickerViewport {
list_rows: 2,
preview_rows: 1,
preview_width: 8,
preview_visible: true,
}),
..KeyViewports::default()
};
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Tab), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(
state.modals.session_picker.as_ref().unwrap().focus,
crate::tui::state::SessionPickerFocus::Preview
);
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Down), &mut state, viewports, &[]),
InputAction::None
);
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!(
handle_key_with_all_viewports(
modified_key(KeyCode::BackTab, KeyModifiers::SHIFT),
&mut state,
viewports,
&[]
),
InputAction::None
);
assert_eq!(
state.modals.session_picker.as_ref().unwrap().focus,
crate::tui::state::SessionPickerFocus::List
);
}
#[test]
fn session_picker_tab_keeps_list_focus_when_preview_column_hidden() {
let mut state = MissionControlState::default();
state.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
}],
1,
);
let viewports = KeyViewports {
sessions_modal: Some(SessionPickerViewport {
list_rows: 1,
preview_rows: 1,
preview_width: 1,
preview_visible: false,
}),
..KeyViewports::default()
};
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Tab), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(
state.modals.session_picker.as_ref().unwrap().focus,
crate::tui::state::SessionPickerFocus::List
);
}
#[test]
fn session_picker_enter_selects_focused_row_and_empty_enter_noops() {
let mut state = MissionControlState::default();
state.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
}],
1,
);
assert!(
matches!(handle_key(key(KeyCode::Enter), &mut state), InputAction::SelectSession(id) if id == "session-a")
);
assert!(!state.session_picker_visible());
state.open_session_picker(Vec::new(), 1);
assert!(matches!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::None
));
assert!(state.session_picker_visible());
}
#[test]
fn session_picker_escape_closes_without_selection() {
let mut state = MissionControlState::default();
state.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".into(),
label: "session-a".into(),
is_current: false,
preview: None,
}],
1,
);
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.session_picker_visible());
}
#[test]
fn skills_modal_keys_toggle_and_do_not_edit_prompt() {
let mut state = MissionControlState {
input: "keep".to_string(),
..Default::default()
};
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.open_skills_modal(
vec![
crate::tui::state::SkillToggleRow {
name: "a".to_string(),
source: "test".to_string(),
enabled: true,
},
crate::tui::state::SkillToggleRow {
name: "b".to_string(),
source: "test".to_string(),
enabled: true,
},
],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
match handle_key(key(KeyCode::Enter), &mut state) {
InputAction::SetSkillEnabled { name, enabled } => {
assert_eq!(name, "b");
assert!(!enabled);
}
other => panic!("unexpected action: {other:?}"),
}
assert_eq!(state.input, "keep");
assert_eq!(state.transcript_scroll_offset(), 3);
assert!(state.skills_modal_visible());
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.skills_modal_visible());
}
#[test]
fn tools_modal_keys_toggle_and_do_not_edit_prompt() {
let mut state = MissionControlState {
input: "keep".to_string(),
..Default::default()
};
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.open_tools_modal(
vec![
crate::tui::state::ToolToggleRow {
name: "bash".to_string(),
kind: "built-in".to_string(),
enabled: true,
},
crate::tui::state::ToolToggleRow {
name: "mcp__mock__echo".to_string(),
kind: "mcp:mock".to_string(),
enabled: true,
},
],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
match handle_key(key(KeyCode::Enter), &mut state) {
InputAction::SetToolEnabled { name, enabled } => {
assert_eq!(name, "mcp__mock__echo");
assert!(!enabled);
}
other => panic!("unexpected action: {other:?}"),
}
assert_eq!(state.input, "keep");
assert_eq!(state.transcript_scroll_offset(), 3);
assert!(state.tools_modal_visible());
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.tools_modal_visible());
}
#[test]
fn subagents_modal_keys_toggle_and_do_not_edit_prompt() {
let mut state = MissionControlState {
input: "keep".to_string(),
..Default::default()
};
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.open_subagents_modal(
vec![
crate::tui::state::SubagentProfileToggleRow {
id: "a".to_string(),
description: "A".to_string(),
enabled: true,
},
crate::tui::state::SubagentProfileToggleRow {
id: "b".to_string(),
description: "B".to_string(),
enabled: true,
},
],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
match handle_key(key(KeyCode::Enter), &mut state) {
InputAction::SetSubagentProfileEnabled { id, enabled } => {
assert_eq!(id, "b");
assert!(!enabled);
}
other => panic!("unexpected action: {other:?}"),
}
assert_eq!(state.input, "keep");
assert_eq!(state.transcript_scroll_offset(), 3);
assert!(state.subagents_modal_visible());
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.subagents_modal_visible());
}
#[test]
fn models_modal_keys_toggle_move_close_and_do_not_edit_prompt() {
let mut state = MissionControlState {
input: "keep".to_string(),
..Default::default()
};
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.open_models_modal_scoped(
vec![
crate::tui::state::ModelToggleRow {
id: "openai/a".to_string(),
provider: "openai".to_string(),
display_name: "A".to_string(),
enabled: true,
},
crate::tui::state::ModelToggleRow {
id: "openai/b".to_string(),
provider: "openai".to_string(),
display_name: "B".to_string(),
enabled: true,
},
],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
crate::config::SettingsScope::Global,
"Global".to_string(),
false,
);
assert_eq!(
handle_key(key(KeyCode::Tab), &mut state),
InputAction::ToggleModelsSettingsScope
);
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
assert!(matches!(
handle_key(key(KeyCode::Up), &mut state),
InputAction::None
));
assert!(matches!(
handle_key(key(KeyCode::Down), &mut state),
InputAction::None
));
match handle_key(key(KeyCode::Enter), &mut state) {
InputAction::SetModelEnabled { id, enabled } => {
assert_eq!(id, "openai/b");
assert!(!enabled);
}
other => panic!("unexpected action: {other:?}"),
}
assert_eq!(state.input, "keep");
assert_eq!(state.transcript_scroll_offset(), 3);
assert!(state.models_modal_visible());
assert!(matches!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::None
));
assert!(!state.models_modal_visible());
}
#[test]
fn models_modal_enter_on_loading_or_empty_is_noop_and_consumes_input() {
let mut state = MissionControlState {
input: "draft".to_string(),
..Default::default()
};
state.open_models_modal_scoped(
Vec::new(),
DEFAULT_PICKER_VISIBLE_ROWS as usize,
crate::config::SettingsScope::Global,
"Global".to_string(),
true,
);
assert_eq!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::None
);
assert_eq!(state.input, "draft");
assert!(state.models_modal_visible());
state.set_models_rows(Vec::new(), false, DEFAULT_PICKER_VISIBLE_ROWS as usize);
assert_eq!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::None
);
assert_eq!(state.input, "draft");
}
#[test]
fn models_modal_uses_actual_viewport_to_keep_selection_visible() {
let mut state = MissionControlState::default();
state.open_models_modal_scoped(
(0..6)
.map(|index| crate::tui::state::ModelToggleRow {
id: format!("openai/gpt-{index}"),
provider: "openai".to_string(),
display_name: format!("GPT {index}"),
enabled: true,
})
.collect(),
2,
crate::config::SettingsScope::Global,
"Global".to_string(),
false,
);
for _ in 0..3 {
assert!(matches!(
handle_key_with_all_viewports(
key(KeyCode::Down),
&mut state,
KeyViewports {
models_modal: Some(PickerViewport { visible_rows: 2 }),
..KeyViewports::default()
},
&[]
),
InputAction::None
));
}
let modal = state.modals.models_modal.as_ref().unwrap();
assert_eq!(modal.selected, 3);
assert_eq!(state.scroll_offset(&state.scroll_views.models_modal), 2);
}
#[test]
fn tab_in_settings_modals_returns_scope_toggle_actions() {
let mut skills = MissionControlState::default();
skills.open_skills_modal(
vec![crate::tui::state::SkillToggleRow {
name: "a".to_string(),
source: "test".to_string(),
enabled: true,
}],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert_eq!(
handle_key(key(KeyCode::Tab), &mut skills),
InputAction::ToggleSkillsSettingsScope
);
let mut tools = MissionControlState::default();
tools.open_tools_modal(
vec![crate::tui::state::ToolToggleRow {
name: "bash".to_string(),
kind: "built-in".to_string(),
enabled: true,
}],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert_eq!(
handle_key(key(KeyCode::Tab), &mut tools),
InputAction::ToggleToolsSettingsScope
);
let mut subagents = MissionControlState::default();
subagents.open_subagents_modal(
vec![crate::tui::state::SubagentProfileToggleRow {
id: "reviewer".to_string(),
description: String::new(),
enabled: true,
}],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
assert_eq!(
handle_key(key(KeyCode::Tab), &mut subagents),
InputAction::ToggleSubagentsSettingsScope
);
let mut models = MissionControlState::default();
models.open_models_modal_scoped(
vec![crate::tui::state::ModelToggleRow {
id: "openai/gpt-test".to_string(),
provider: "openai".to_string(),
display_name: "GPT Test".to_string(),
enabled: true,
}],
DEFAULT_PICKER_VISIBLE_ROWS as usize,
crate::config::SettingsScope::Global,
"Global".to_string(),
false,
);
assert_eq!(
handle_key(key(KeyCode::Tab), &mut models),
InputAction::ToggleModelsSettingsScope
);
}
#[test]
fn skills_modal_uses_actual_viewport_to_keep_selection_visible() {
let mut state = MissionControlState::default();
state.open_skills_modal(
(0..6)
.map(|index| crate::tui::state::SkillToggleRow {
name: format!("skill-{index}"),
source: "test".to_string(),
enabled: true,
})
.collect(),
2,
);
for _ in 0..3 {
assert!(matches!(
handle_key_with_all_viewports(
key(KeyCode::Down),
&mut state,
KeyViewports::default(),
&[]
),
InputAction::None
));
}
let modal = state.modals.skills_modal.as_ref().unwrap();
assert_eq!(modal.selected, 3);
assert_eq!(state.scroll_offset(&state.scroll_views.skills_modal), 0);
}
#[test]
fn logout_confirmation_keys_confirm_cancel_and_own_input() {
let mut state = MissionControlState {
input: "draft".to_string(),
..Default::default()
};
state.open_logout_confirmation("openai-codex".to_string(), "OpenAI Codex".to_string());
assert!(matches!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::ConfirmLogout(provider) if provider == "openai-codex"
));
assert!(state.modals.logout_confirmation.is_none());
assert_eq!(state.input, "draft");
state.open_logout_confirmation("openai-codex".to_string(), "OpenAI Codex".to_string());
assert_eq!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::CancelLogout
);
assert!(state.modals.logout_confirmation.is_none());
assert_eq!(state.input, "draft");
}
#[test]
fn custom_provider_replacement_keys_confirm_cancel_and_own_input() {
let mut state = MissionControlState {
input: "draft".to_string(),
..Default::default()
};
state.open_custom_provider_replacement_confirmation(
"custom-local".to_string(),
"Old Local".to_string(),
"New Local".to_string(),
"https://example.test".to_string(),
Some("LOCAL_API_KEY".to_string()),
);
assert_eq!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::ConfirmCustomProviderReplacement
);
assert!(
state
.modals
.custom_provider_replacement_confirmation
.is_some()
);
assert_eq!(state.input, "draft");
assert_eq!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::CancelCustomProviderReplacement
);
assert!(
state
.modals
.custom_provider_replacement_confirmation
.is_none()
);
assert_eq!(state.input, "draft");
}
#[test]
fn custom_provider_setup_keys_edit_setup_only_and_submit_or_cancel() {
let mut state = MissionControlState {
input: "draft".to_string(),
..Default::default()
};
state.start_custom_provider_setup();
assert_eq!(
handle_key(key(KeyCode::Char('L')), &mut state),
InputAction::None
);
assert_eq!(
state.modals.custom_provider_setup.as_ref().unwrap().label,
"L".to_string()
);
assert_eq!(state.input, "draft");
assert_eq!(
handle_key(key(KeyCode::Backspace), &mut state),
InputAction::None
);
assert_eq!(
state.modals.custom_provider_setup.as_ref().unwrap().label,
""
);
assert_eq!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::SubmitCustomProviderSetupStep
);
assert_eq!(
handle_key(key(KeyCode::Esc), &mut state),
InputAction::CancelLogin
);
assert!(state.modals.custom_provider_setup.is_some());
assert_eq!(state.input, "draft");
}
#[test]
fn modal_priority_prefers_session_picker_over_later_modals() {
let mut state = MissionControlState::default();
state.open_skills_modal(
vec![crate::tui::state::SkillToggleRow {
name: "skill-a".to_string(),
source: "test".to_string(),
enabled: true,
}],
1,
);
state.open_session_picker(
vec![crate::tui::state::SessionPickerRow {
id: "session-a".to_string(),
label: "session-a".to_string(),
is_current: false,
preview: None,
}],
1,
);
assert!(matches!(
handle_key(key(KeyCode::Enter), &mut state),
InputAction::SelectSession(session) if session == "session-a"
));
assert!(!state.session_picker_visible());
assert!(state.skills_modal_visible());
assert!(state.modals.skills_modal.as_ref().unwrap().rows[0].enabled);
}
#[test]
fn alt_c_cancels_running_prompt_without_editing_prompt() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
input: "draft".to_string(),
prompt_cursor: 5,
..Default::default()
};
state.start_running_prompt("active".to_string());
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('c'), KeyModifiers::ALT),
&mut state
),
InputAction::CancelRunningPrompt
));
assert_eq!(state.input, "draft");
assert_eq!(state.prompt_cursor, 5);
}
#[test]
fn alt_c_running_prompt_takes_priority_over_activity_collapse() {
let mut state = state_with_parent_and_child();
state.start_running_prompt("active".to_string());
assert_eq!(state.visible_nodes().len(), 2);
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('c'), KeyModifiers::ALT),
&mut state
),
InputAction::CancelRunningPrompt
));
assert_eq!(state.visible_nodes().len(), 2);
}
#[test]
fn alt_c_idle_prompt_focus_does_not_mutate_prompt_or_submit() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
input: "draft".to_string(),
prompt_cursor: 5,
..Default::default()
};
assert!(matches!(
handle_key(
modified_key(KeyCode::Char('c'), KeyModifiers::ALT),
&mut state
),
InputAction::None
));
assert_eq!(state.input, "draft");
assert_eq!(state.prompt_cursor, 5);
assert!(state.transcript.is_empty());
}
#[test]
fn alt_c_release_does_not_cancel_running_prompt() {
let mut state = MissionControlState::default();
state.start_running_prompt("active".to_string());
let key = KeyEvent::new_with_kind(
KeyCode::Char('c'),
KeyModifiers::ALT,
crossterm::event::KeyEventKind::Release,
);
assert!(matches!(handle_key(key, &mut state), InputAction::None));
}