use super::*;
const PASTE_BURST_THRESHOLD_MS: u128 = 10;
#[derive(Debug, Default)]
struct DrainLoopBudgetState {
draw_before_next_drain: bool,
continue_after_pending_draw: bool,
}
impl DrainLoopBudgetState {
fn should_drain_at_loop_top(&self) -> bool {
!self.draw_before_next_drain
}
fn apply_outcome(&mut self, outcome: &DrainOutcome, redraw_intent: &mut RedrawIntent) {
if outcome.result.changed {
redraw_intent.request_immediate();
}
if outcome.exhausted && outcome.result.changed {
self.draw_before_next_drain = true;
self.continue_after_pending_draw = true;
}
}
fn after_draw(&mut self) -> bool {
self.draw_before_next_drain = false;
std::mem::take(&mut self.continue_after_pending_draw)
}
}
fn should_record_key_event_at(key: &crossterm::event::KeyEvent) -> bool {
key.kind != crossterm::event::KeyEventKind::Release
}
fn should_treat_enter_as_paste_newline(
state: &state::MissionControlState,
key: &crossterm::event::KeyEvent,
now: Instant,
threshold_ms: u128,
) -> bool {
state
.last_key_event_at
.is_some_and(|last| now.duration_since(last).as_millis() < threshold_ms)
&& key.kind != crossterm::event::KeyEventKind::Release
&& key.code == crossterm::event::KeyCode::Enter
&& key.modifiers == crossterm::event::KeyModifiers::NONE
&& state.is_prompt_focused()
&& !state.modal_active()
}
fn draw_initial_frame<B>(
terminal: &mut ratatui::Terminal<B>,
state: &mut state::MissionControlState,
) -> Result<()>
where
B: ratatui::backend::Backend,
B::Error: Send + Sync + 'static,
{
terminal.draw(|frame| render::draw(frame, state))?;
Ok(())
}
fn load_startup_data(
config: EffectiveConfig,
manager: SessionManager,
active_session_id: Option<String>,
cwd: PathBuf,
skills: SkillDiscovery,
model: String,
provider: String,
) -> TuiStartupLoaded {
let settings = crate::config::read_settings(&config.paths).ok();
let subagent_profile_discovery =
crate::subagents::profiles::discover_subagent_profiles(&config.paths.subagents);
let primary_agent_discovery =
crate::primary_agents::discover_primary_agent_profiles(&config.paths.primary_agents);
let (persisted_primary_agent, persisted_primary_agent_error) =
match crate::config::selected_primary_agent(&config.paths) {
Ok(selected) => (selected, None),
Err(error) => (None, Some(error.to_string())),
};
let active_latest_title = active_session_id.and_then(|active_id| {
manager
.list_metadata_summaries()
.ok()?
.into_iter()
.find(|summary| summary.session.id() == active_id)
.and_then(|summary| summary.latest_title)
});
let file_autocomplete_candidates =
tui_file_autocomplete_candidates(&cwd, config.file_autocomplete_respects_gitignore);
let skill_autocomplete_candidates = tui_skill_autocomplete_candidates(&skills);
let thinking_levels = cached_thinking_levels_for_model(
&config.paths,
&provider,
&model,
crate::thinking::capability_scope_for_provider(&config.custom_providers, &provider),
);
let footer_git_branch = crate::tui::sessions::commands::current_git_branch(&cwd);
TuiStartupLoaded {
settings,
subagent_profile_discovery,
primary_agent_discovery,
persisted_primary_agent,
persisted_primary_agent_error,
active_latest_title,
file_autocomplete_candidates,
skill_autocomplete_candidates,
thinking_levels,
footer_git_branch,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingSessionPreview {
request_id: u64,
session_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingSessionSwitch {
request_id: u64,
session_id: String,
generation: u64,
}
enum PendingModelCatalogConsumer {
SetModelPicker,
ModelsModal,
}
pub(crate) struct MissionControlApp {
pub(crate) config: EffectiveConfig,
pub(crate) instructions: Vec<InstructionFile>,
pub(crate) discovered_skills: SkillDiscovery,
pub(crate) subagent_profile_discovery: crate::subagents::profiles::SubagentProfileDiscovery,
pub(crate) skills: SkillDiscovery,
pub(crate) commands: CommandRegistry,
pub(crate) state: ShellState,
pub(crate) events: Sender<TuiEvent>,
pub(crate) active_run: bool,
pub(crate) last_steering_pending_count: usize,
pub(crate) worker: Option<WorkerState>,
pub(crate) model_catalog_loading: bool,
pub(crate) next_model_catalog_request_id: u64,
pub(crate) next_usage_request_id: u64,
model_catalog_cache: Option<crate::model_catalog::CatalogForUi>,
pending_model_catalog_consumer: Option<PendingModelCatalogConsumer>,
pub(crate) steering: crate::agent::steering::AgentSteering,
pub(crate) disabled_tools: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
pub(crate) disabled_subagent_profiles:
std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
pub(crate) mcp: Option<std::sync::Arc<std::sync::Mutex<crate::mcp::manager::McpManager>>>,
pub(crate) startup_policy_loaded: bool,
pub(crate) next_startup_request_id: u64,
session_generation: u64,
next_session_preview_request_id: u64,
active_session_preview: Option<PendingSessionPreview>,
queued_session_preview: Option<String>,
next_session_switch_request_id: u64,
pending_session_switch: Option<PendingSessionSwitch>,
next_footer_git_branch_request_id: u64,
pending_footer_git_branch_request_id: Option<u64>,
#[cfg(any(test, debug_assertions))]
tui_perf_enabled: bool,
}
mod actions;
mod auth;
mod catalog;
mod compaction;
mod modals;
mod reducer;
mod submit;
mod workers;
impl MissionControlApp {
pub(crate) fn new(config: TuiSessionConfig, events: Sender<TuiEvent>) -> Self {
let model = config
.config
.model
.clone()
.unwrap_or_else(|| crate::providers::DEFAULT_CODEX_MODEL.to_string());
let state = ShellState::new(
config.manager,
config.active_session,
config.cwd,
model,
config.config.auth_state(),
)
.with_config(config.config.clone());
Self {
config: config.config,
instructions: config.instructions,
discovered_skills: config.discovered_skills,
subagent_profile_discovery:
crate::subagents::profiles::SubagentProfileDiscovery::default(),
skills: config.skills,
commands: config.commands,
state,
events,
active_run: false,
last_steering_pending_count: 0,
worker: None,
model_catalog_loading: false,
next_model_catalog_request_id: 0,
next_usage_request_id: 0,
model_catalog_cache: None,
pending_model_catalog_consumer: None,
steering: crate::agent::steering::AgentSteering::new(),
disabled_tools: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
disabled_subagent_profiles: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
mcp: None,
startup_policy_loaded: false,
next_startup_request_id: 0,
session_generation: 0,
next_session_preview_request_id: 0,
active_session_preview: None,
queued_session_preview: None,
next_session_switch_request_id: 0,
pending_session_switch: None,
next_footer_git_branch_request_id: 0,
pending_footer_git_branch_request_id: None,
#[cfg(any(test, debug_assertions))]
tui_perf_enabled: crate::tui::perf::TuiPerfCounters::from_env().enabled(),
}
}
fn start_startup_loader(&mut self) -> u64 {
self.next_startup_request_id = self.next_startup_request_id.saturating_add(1);
let request_id = self.next_startup_request_id;
let sender = self.events.clone();
let config = self.config.clone();
let manager = self.state.session_manager.clone();
let active_session_id = self.state.active_session_id().map(ToOwned::to_owned);
let cwd = self.state.cwd.clone();
let skills = self.skills.clone();
let model = self.state.model.clone();
let provider = self.state.auth_state.provider().to_string();
thread::spawn(move || {
let result = std::panic::catch_unwind(|| {
load_startup_data(
config,
manager,
active_session_id,
cwd,
skills,
model,
provider,
)
})
.map_err(|_| "startup loader panicked".to_string());
let _ = send_tui_event(
&sender,
TuiEvent::StartupLoaded {
request_id,
result: Box::new(result),
},
);
});
request_id
}
fn apply_startup_loaded(
&mut self,
ui_state: &mut state::MissionControlState,
loaded: TuiStartupLoaded,
autocomplete_candidates: &mut Vec<state::AutocompleteCandidate>,
) {
if let Some(settings) = &loaded.settings {
if let Ok(mut disabled_tools) = self.disabled_tools.lock() {
*disabled_tools = crate::config::disabled_tool_names_from_settings(settings)
.into_iter()
.collect();
}
if let Ok(mut disabled_subagents) = self.disabled_subagent_profiles.lock() {
*disabled_subagents =
crate::config::disabled_subagent_profile_names_from_settings(settings)
.into_iter()
.collect();
}
self.skills = filter_enabled_skills(
&self.discovered_skills,
&crate::config::disabled_skill_names_from_settings(settings),
);
self.mcp = (!settings.mcp_servers.is_empty()).then(|| {
std::sync::Arc::new(std::sync::Mutex::new(
crate::mcp::manager::McpManager::from_settings_with_paths(
&settings.mcp_servers,
Some(&self.config.paths.root),
),
))
});
}
self.subagent_profile_discovery = loaded.subagent_profile_discovery;
autocomplete_candidates.extend(loaded.file_autocomplete_candidates);
autocomplete_candidates.extend(loaded.skill_autocomplete_candidates);
ui_state.refresh_thinking_levels(self.config.thinking_level, loaded.thinking_levels);
if let Some(error) = loaded.persisted_primary_agent_error {
record_primary_agent_startup_diagnostics(
ui_state,
&[crate::primary_agents::PrimaryAgentProfileDiagnostic {
id: None,
path: None,
message: format!(
"could not read stored primary agent selection; using None: {error}"
),
}],
);
}
let persisted_resolution = resolve_persisted_primary_agent_selection(
loaded.persisted_primary_agent.as_deref(),
&loaded.primary_agent_discovery.profiles,
);
ui_state.set_primary_agents(
loaded
.primary_agent_discovery
.profiles
.into_values()
.map(Into::into)
.collect(),
);
ui_state.set_selected_primary_agent_id(persisted_resolution.selected_id.as_deref());
record_primary_agent_startup_diagnostics(
ui_state,
&loaded.primary_agent_discovery.diagnostics,
);
record_primary_agent_startup_diagnostics(ui_state, &persisted_resolution.diagnostics);
apply_footer_context(
ui_state,
self.state.current_session.as_ref(),
loaded.active_latest_title.as_deref(),
&self.state.cwd,
);
ui_state.footer_git_branch = loaded.footer_git_branch;
self.startup_policy_loaded = true;
if ui_state.status == "starting Mission Control…" {
ui_state.status = "Mission Control ready".to_string();
}
}
fn handle_startup_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
autocomplete_candidates: &mut Vec<state::AutocompleteCandidate>,
) -> bool {
let mut changed = false;
for (_request_id, loaded) in &drain_result.startup_loaded {
match loaded {
Ok(loaded) => {
self.apply_startup_loaded(ui_state, loaded.clone(), autocomplete_candidates);
changed = true;
}
Err(error) => {
ui_state.record_error_transcript(error);
ui_state.status = error.clone();
changed = true;
}
}
}
changed
}
fn request_footer_git_branch_refresh(&mut self) {
if self.pending_footer_git_branch_request_id.is_some() {
return;
}
self.next_footer_git_branch_request_id =
self.next_footer_git_branch_request_id.saturating_add(1);
let request_id = self.next_footer_git_branch_request_id;
self.pending_footer_git_branch_request_id = Some(request_id);
let cwd = self.state.cwd.clone();
let sender = self.events.clone();
thread::spawn(move || {
let branch = crate::tui::sessions::commands::current_git_branch(&cwd);
let _ = send_tui_event(
&sender,
TuiEvent::FooterGitBranchLoaded { request_id, branch },
);
});
}
fn handle_footer_git_branch_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
for (request_id, branch) in &drain_result.footer_git_branch_loaded {
if self.pending_footer_git_branch_request_id != Some(*request_id) {
continue;
}
self.pending_footer_git_branch_request_id = None;
if ui_state.footer_git_branch != *branch {
ui_state.footer_git_branch = branch.clone();
changed = true;
}
}
changed
}
fn handle_background_session_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
receiver: &Receiver<TuiEvent>,
) -> bool {
let mut changed = self.handle_session_preview_drain(ui_state, drain_result);
changed |= self.handle_session_switch_drain(ui_state, drain_result, receiver);
changed |= self.handle_footer_git_branch_drain(ui_state, drain_result);
changed
}
fn handle_session_switch_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
receiver: &Receiver<TuiEvent>,
) -> bool {
let mut changed = false;
for (request_id, session_id, result) in &drain_result.session_switch_loaded {
let Some(pending) = self.pending_session_switch.clone() else {
continue;
};
if pending.request_id != *request_id || pending.session_id != *session_id {
continue;
}
self.pending_session_switch = None;
match result {
Ok(loaded) => {
if self.active_run
|| self.session_generation != pending.generation
|| self.state.active_session_id() == Some(session_id.as_str())
{
ui_state.status =
"session switch result ignored; state changed".to_string();
changed = true;
continue;
}
if let Err(error) = preserve_critical_tui_events(receiver, &self.events) {
ui_state.status = error.to_string();
changed = true;
continue;
}
self.state.current_session = Some(loaded.session.clone());
self.session_generation = self.session_generation.saturating_add(1);
ui_state.reset_for_new_session();
apply_session_hydration_snapshot(ui_state, loaded.snapshot.clone());
for diagnostic in &loaded.diagnostics {
ui_state.apply_output_event(&OutputEvent::Diagnostic {
level: "warning".to_string(),
message: diagnostic.clone(),
});
}
apply_footer_context(
ui_state,
self.state.current_session.as_ref(),
loaded.latest_title.as_deref(),
&self.state.cwd,
);
ui_state.close_session_picker();
ui_state.status = loaded.status.clone();
changed = true;
}
Err(error) => {
ui_state.status = error.clone();
changed = true;
}
}
}
changed
}
pub(crate) fn run(&mut self, receiver: Receiver<TuiEvent>) -> Result<()> {
let _terminal_guard = terminal::TerminalGuard::enter()?;
let mut terminal =
ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(std::io::stdout()))?;
let provider = self.state.auth_state.provider().to_string();
let model = self.state.model.clone();
let thinking_levels = cached_thinking_levels_for_model(
&self.config.paths,
&provider,
&model,
crate::thinking::capability_scope_for_provider(
&self.config.custom_providers,
&provider,
),
);
let mut state = state::MissionControlState {
provider,
model,
provider_ready: self.state.auth_state.is_ready(),
thinking_level: self.config.thinking_level,
thinking_levels: thinking_levels.clone(),
focus_pane: state::TuiFocusPane::Prompt,
prompt_cursor_visible: true,
status: "starting Mission Control…".to_string(),
footer_session: self
.state
.current_session
.as_ref()
.map(|session| session.id().to_string())
.unwrap_or_else(|| "<disabled>".to_string()),
footer_session_id: self
.state
.current_session
.as_ref()
.map(|session| session.id().to_string()),
footer_cwd: self.state.cwd.display().to_string(),
..Default::default()
};
#[cfg(any(test, debug_assertions))]
{
state.perf = crate::tui::perf::TuiPerfCounters::with_enabled(self.tui_perf_enabled);
}
state.refresh_thinking_levels(self.config.thinking_level, thinking_levels);
let mut autocomplete_candidates = tui_autocomplete_candidates(&self.commands);
draw_initial_frame(&mut terminal, &mut state)?;
let startup_request_id = self.start_startup_loader();
state.pending_startup_request_id = Some(startup_request_id);
let mut redraw_intent = RedrawIntent::Immediate;
let mut pending_input_events = VecDeque::new();
let mut last_area = ratatui::layout::Rect::default();
let mut last_prompt_cursor_blink = Instant::now();
let mut last_footer_git_branch_refresh = Instant::now();
let mut clipboard = input::clipboard::Osc52Clipboard;
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
loop {
let drain_outcome = if drain_budget_state.should_drain_at_loop_top() {
#[cfg(any(test, debug_assertions))]
{
if state.perf.enabled() {
state.perf.set_backlog_before_drain(receiver.len());
let started = Instant::now();
let outcome =
drain_tui_events_budgeted(&receiver, &mut state, UI_DRAIN_BUDGET);
state.perf.record_drain_us(
Instant::now().duration_since(started).as_micros() as u64,
);
state.perf.set_backlog_after_drain(receiver.len());
outcome
} else {
drain_tui_events_budgeted(&receiver, &mut state, UI_DRAIN_BUDGET)
}
}
#[cfg(not(any(test, debug_assertions)))]
{
drain_tui_events_budgeted(&receiver, &mut state, UI_DRAIN_BUDGET)
}
} else {
DrainOutcome::default()
};
let drain_result = &drain_outcome.result;
if self.handle_model_catalog_drain(&mut state, drain_result) {
redraw_intent.request_immediate();
}
if self.handle_startup_drain(&mut state, drain_result, &mut autocomplete_candidates) {
redraw_intent.request_immediate();
}
if self.handle_background_session_drain(&mut state, drain_result, &receiver) {
redraw_intent.request_immediate();
}
if self.sync_steering_feedback(&mut state) {
redraw_intent.request_immediate();
}
if drain_result.run_finished {
self.join_completed_worker(&mut state);
self.active_run = false;
}
if self
.worker
.as_ref()
.is_some_and(|worker| worker.handle.is_finished())
{
self.finish_worker_if_ready(&mut state);
redraw_intent.request_immediate();
}
drain_budget_state.apply_outcome(&drain_outcome, &mut redraw_intent);
if drain_result.model_catalog_finished {
self.model_catalog_loading = false;
}
if drain_outcome.exhausted && !drain_outcome.result.changed {
continue;
}
let now = Instant::now();
if now.duration_since(last_footer_git_branch_refresh)
>= FOOTER_GIT_BRANCH_REFRESH_INTERVAL
{
self.request_footer_git_branch_refresh();
last_footer_git_branch_refresh = now;
}
if state.expire_toast(now) {
redraw_intent.request_immediate();
}
if prompt_cursor_blink_due(&state, last_prompt_cursor_blink) {
state.toggle_prompt_cursor();
last_prompt_cursor_blink = Instant::now();
redraw_intent.request_immediate();
}
if should_draw_frame(redraw_intent) {
state.begin_final_assistant_plain_first_paint();
#[cfg(any(test, debug_assertions))]
let draw_started = if state.perf.enabled() {
state.perf.set_backlog_before_draw(receiver.len());
state.perf.reset_projected_cards();
Some(Instant::now())
} else {
None
};
terminal.draw(|frame| {
last_area = frame.area();
render::draw(frame, &state);
})?;
#[cfg(any(test, debug_assertions))]
if let Some(started) = draw_started {
state
.perf
.record_draw_us(Instant::now().duration_since(started).as_micros() as u64);
}
redraw_intent.clear();
let continue_after_pending_draw = drain_budget_state.after_draw();
if state.finish_final_assistant_plain_first_paint() {
redraw_intent.request_immediate();
continue;
}
if continue_after_pending_draw {
continue;
}
}
let input_event = if let Some(event) = pending_input_events.pop_front() {
event
} else {
let now = Instant::now();
let timeout = next_ui_timer_timeout(
&state,
last_prompt_cursor_blink,
last_footer_git_branch_refresh,
now,
);
crossbeam_channel::select! {
recv(receiver) -> event => {
match event {
Ok(event) => {
#[cfg(any(test, debug_assertions))]
if state.perf.enabled() {
state.perf.set_backlog_before_drain(receiver.len().saturating_add(1));
}
let budget_started = Instant::now();
let mut drain_outcome = DrainOutcome::default();
apply_tui_event_to_state(
&mut state,
event,
&mut drain_outcome.result,
);
let tail_outcome = drain_tui_events_budgeted_from(
&receiver,
&mut state,
UI_DRAIN_BUDGET,
1,
budget_started,
Instant::now,
);
drain_outcome.processed = tail_outcome.processed;
drain_outcome.exhausted = tail_outcome.exhausted;
drain_outcome.result.merge(tail_outcome.result);
#[cfg(any(test, debug_assertions))]
if state.perf.enabled() {
state.perf.record_drain_us(
Instant::now().duration_since(budget_started).as_micros()
as u64,
);
state.perf.set_backlog_after_drain(receiver.len());
}
if self.handle_model_catalog_drain(&mut state, &drain_outcome.result) {
redraw_intent.request_immediate();
}
if self.handle_startup_drain(
&mut state,
&drain_outcome.result,
&mut autocomplete_candidates,
) {
redraw_intent.request_immediate();
}
if self.handle_background_session_drain(
&mut state,
&drain_outcome.result,
&receiver,
) {
redraw_intent.request_immediate();
}
if self.sync_steering_feedback(&mut state) {
redraw_intent.request_immediate();
}
if drain_outcome.result.run_finished {
self.join_completed_worker(&mut state);
self.active_run = false;
}
drain_budget_state.apply_outcome(
&drain_outcome,
&mut redraw_intent,
);
if drain_outcome.result.model_catalog_finished {
self.model_catalog_loading = false;
}
continue;
}
Err(_) => break,
}
}
recv(input_events.receiver) -> event => {
match event {
Ok(event) => event,
Err(_) => break,
}
}
default(timeout.unwrap_or(Duration::MAX)) => {
continue;
}
}
};
match input_event {
crossterm::event::Event::Key(key) => {
let focus_before = state.focus_pane;
let now = Instant::now();
let treat_enter_as_paste_newline = should_treat_enter_as_paste_newline(
&state,
&key,
now,
PASTE_BURST_THRESHOLD_MS,
);
if should_record_key_event_at(&key) {
state.last_key_event_at = Some(now);
}
let viewports = viewports::key_viewports(last_area, &state);
if treat_enter_as_paste_newline {
let prompt_viewport = viewports.prompt.unwrap_or(input::PromptViewport {
visible_rows: 1,
wrap_width: 80,
});
state.insert_prompt_char(
'\n',
prompt_viewport.visible_rows.max(1),
prompt_viewport.wrap_width.max(1),
);
redraw_intent.request_immediate();
continue;
}
let action = input::handle_key_with_all_viewports(
key,
&mut state,
viewports,
&autocomplete_candidates,
);
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
if matches!(
self.apply_input_action(action.into(), &mut state, &mut action_context),
actions::AppControlFlow::Exit
) {
break;
}
if state.session_picker_visible() {
self.refresh_selected_session_preview(&mut state);
}
let _ = focus_before;
redraw_intent.request_immediate();
}
crossterm::event::Event::Paste(text) => {
let paste_viewports = viewports::paste_viewports(last_area, &state);
let _ = input::handle_paste_with_all_viewports(
&text,
&mut state,
paste_viewports,
&autocomplete_candidates,
);
redraw_intent.request_immediate();
}
crossterm::event::Event::Mouse(mouse) => {
let focus_before = state.focus_pane;
let (mouse, scroll_rows) = coalesce_mouse_scroll_event(
mouse,
last_area,
&state,
&input_events.receiver,
&mut pending_input_events,
);
let scroll_only = mouse_scroll_batch(mouse, last_area, &state).is_some();
let result = if scroll_only {
handle_mouse_event_with_scroll_rows(
mouse,
last_area,
&mut state,
scroll_rows,
)
} else {
handle_mouse_event(mouse, last_area, &mut state)
};
if let Some(text) = result.copy {
if apply_copy_request(&text, &mut state, &mut clipboard, Instant::now()) {
redraw_intent.request_immediate();
}
} else if result.changed {
if scroll_only {
redraw_intent.request_scroll();
} else {
redraw_intent.request_immediate();
}
}
if state.session_picker_visible()
&& self.refresh_selected_session_preview(&mut state)
{
redraw_intent.request_immediate();
}
let _ = focus_before;
}
crossterm::event::Event::Resize(_, _) => redraw_intent.request_immediate(),
_ => {}
}
}
self.cancel_active_worker(&mut state);
if !self.join_worker_with_timeout(&mut state, WORKER_EXIT_JOIN_TIMEOUT) {
return Err(anyhow::anyhow!(state.status.clone()));
}
Ok(())
}
pub(crate) fn sync_steering_feedback(
&mut self,
ui_state: &mut state::MissionControlState,
) -> bool {
let pending = self.steering.pending_count();
let mut changed = ui_state.set_pending_steering_count(pending);
if pending == 0 && self.last_steering_pending_count > 0 {
ui_state.show_steering_injected_feedback(Instant::now());
changed = true;
}
self.last_steering_pending_count = pending;
changed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn enter_key() -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)
}
fn state_with_last_key(now: Instant) -> state::MissionControlState {
state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
last_key_event_at: Some(now - Duration::from_millis(1)),
..Default::default()
}
}
fn test_paths(root: &std::path::Path) -> crate::config::McPaths {
crate::config::McPaths {
root: root.join("mc"),
cache: root.join("mc/cache"),
state: root.join("mc/state"),
sessions: root.join("mc/sessions"),
checkpoints: root.join("mc/checkpoints"),
skills: root.join("mc/skills"),
prompts: root.join("mc/prompts"),
subagents: root.join("mc/subagents"),
primary_agents: root.join("mc/primary-agents"),
user_agents: root.join("mc/agents"),
settings_file: root.join("mc/settings.json"),
project_settings_file: root.join("project/.magi-code/settings.json"),
local_settings_file: None,
auth_file: root.join("mc/auth.json"),
}
}
fn test_app(temp: &tempfile::TempDir, sender: Sender<TuiEvent>) -> MissionControlApp {
let mut app = MissionControlApp::new(
TuiSessionConfig {
config: EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("test-model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: test_paths(temp.path()),
},
instructions: Vec::new(),
discovered_skills: SkillDiscovery::default(),
skills: SkillDiscovery::default(),
commands: CommandRegistry::mvp(),
manager: SessionManager::new(temp.path().join("sessions")),
active_session: None,
cwd: temp.path().to_path_buf(),
},
sender,
);
app.startup_policy_loaded = true;
app
}
fn action_context<'a>(
receiver: &'a Receiver<TuiEvent>,
autocomplete_candidates: &'a mut Vec<state::AutocompleteCandidate>,
clipboard: &'a mut input::clipboard::Osc52Clipboard,
) -> actions::ActionContext<'a> {
actions::ActionContext {
receiver,
autocomplete_candidates,
clipboard,
last_area: ratatui::layout::Rect::new(0, 0, 100, 30),
}
}
fn loaded_startup(_temp: &tempfile::TempDir) -> TuiStartupLoaded {
TuiStartupLoaded {
settings: Some(crate::config::Settings::default()),
subagent_profile_discovery:
crate::subagents::profiles::SubagentProfileDiscovery::default(),
primary_agent_discovery: crate::primary_agents::PrimaryAgentProfileDiscovery::default(),
persisted_primary_agent: None,
persisted_primary_agent_error: None,
active_latest_title: Some("Loaded title".to_string()),
file_autocomplete_candidates: vec![state::AutocompleteCandidate::file_tag(
"src/lib.rs",
)],
skill_autocomplete_candidates: vec![state::AutocompleteCandidate::skill_tag(
"rust-dev",
)],
thinking_levels: vec![crate::thinking::ThinkingLevel::Default],
footer_git_branch: Some("main".to_string()),
}
}
fn test_area() -> ratatui::layout::Rect {
ratatui::layout::Rect::new(0, 0, 100, 30)
}
#[test]
fn startup_loaded_applies_once_and_stale_request_is_ignored() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState {
pending_startup_request_id: Some(2),
status: "starting Mission Control…".to_string(),
..Default::default()
};
let mut autocomplete_candidates = tui_autocomplete_candidates(&app.commands);
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupLoaded {
request_id: 1,
result: Box::new(Ok(loaded_startup(&temp))),
},
&mut result,
);
assert!(result.startup_loaded.is_empty());
assert_eq!(ui_state.status, "starting Mission Control…");
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupLoaded {
request_id: 2,
result: Box::new(Ok(loaded_startup(&temp))),
},
&mut result,
);
assert_eq!(result.startup_loaded.len(), 1);
assert!(app.handle_startup_drain(&mut ui_state, &result, &mut autocomplete_candidates));
assert!(app.startup_policy_loaded);
assert_eq!(ui_state.footer_git_branch.as_deref(), Some("main"));
assert!(
autocomplete_candidates
.iter()
.any(|candidate| candidate.name == "src/lib.rs")
);
}
#[test]
fn startup_loaded_preserves_user_prompt_focus_and_modal_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState {
pending_startup_request_id: Some(1),
input: "typed before load".to_string(),
prompt_cursor: 5,
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
};
ui_state.start_custom_provider_setup();
let mut autocomplete_candidates = Vec::new();
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupLoaded {
request_id: 1,
result: Box::new(Ok(loaded_startup(&temp))),
},
&mut result,
);
app.handle_startup_drain(&mut ui_state, &result, &mut autocomplete_candidates);
assert_eq!(ui_state.input, "typed before load");
assert_eq!(ui_state.prompt_cursor, 5);
assert_eq!(ui_state.focus_pane, state::TuiFocusPane::ActivityTree);
assert!(ui_state.modals.custom_provider_setup.is_some());
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn initial_state_draws_before_startup_loaded_event_arrives() {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut ui_state = state::MissionControlState {
provider: "openai".to_string(),
model: "test-model".to_string(),
focus_pane: state::TuiFocusPane::Prompt,
status: "starting Mission Control…".to_string(),
..Default::default()
};
let (sender, receiver) = bounded::<TuiEvent>(1);
draw_initial_frame(&mut terminal, &mut ui_state).unwrap();
assert!(receiver.try_recv().is_err());
assert_eq!(ui_state.status, "starting Mission Control…");
sender
.send(TuiEvent::StartupLoaded {
request_id: 1,
result: Box::new(Ok(loaded_startup(&tempfile::TempDir::new().unwrap()))),
})
.unwrap();
assert!(matches!(
receiver.try_recv(),
Ok(TuiEvent::StartupLoaded { request_id: 1, .. })
));
}
#[test]
fn submit_is_blocked_until_startup_policy_loaded() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = MissionControlApp::new(
TuiSessionConfig {
config: EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("test-model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: test_paths(temp.path()),
},
instructions: Vec::new(),
discovered_skills: SkillDiscovery::default(),
skills: SkillDiscovery::default(),
commands: CommandRegistry::mvp(),
manager: SessionManager::new(temp.path().join("sessions")),
active_session: None,
cwd: temp.path().to_path_buf(),
},
sender,
);
let mut ui_state = state::MissionControlState::default();
assert!(!app.submit("hello".to_string(), &mut ui_state, &receiver, test_area()));
assert_eq!(ui_state.input, "hello");
assert_eq!(
ui_state.status,
"startup policy still loading; prompt execution disabled"
);
assert!(!app.active_run);
}
#[test]
fn session_preview_loaded_applies_selected_row_and_ignores_stale() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.open_session_picker(
vec![state::SessionPickerRow {
id: "target".to_string(),
label: "target".to_string(),
is_current: false,
preview: None,
}],
1,
);
app.active_session_preview = Some(PendingSessionPreview {
request_id: 2,
session_id: "target".to_string(),
});
let mut stale = DrainResult::default();
stale.session_preview_loaded.push((
1,
"target".to_string(),
state::SessionPreviewState::Empty,
));
assert!(!app.handle_session_preview_drain(&mut ui_state, &stale));
assert!(
ui_state.modals.session_picker.as_ref().unwrap().rows[0]
.preview
.is_none()
);
let mut current = DrainResult::default();
current.session_preview_loaded.push((
2,
"target".to_string(),
state::SessionPreviewState::Empty,
));
assert!(app.handle_session_preview_drain(&mut ui_state, ¤t));
assert!(matches!(
ui_state.modals.session_picker.as_ref().unwrap().rows[0].preview,
Some(state::SessionPreviewState::Empty)
));
}
#[test]
fn rapid_session_preview_navigation_coalesces_to_one_active_and_latest_queued() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.open_session_picker(
vec![
state::SessionPickerRow {
id: "one".to_string(),
label: "one".to_string(),
is_current: false,
preview: None,
},
state::SessionPickerRow {
id: "two".to_string(),
label: "two".to_string(),
is_current: false,
preview: None,
},
state::SessionPickerRow {
id: "three".to_string(),
label: "three".to_string(),
is_current: false,
preview: None,
},
],
3,
);
assert!(app.request_selected_session_preview(&mut ui_state));
ui_state.session_picker_down(3);
assert!(!app.request_selected_session_preview(&mut ui_state));
ui_state.session_picker_down(3);
assert!(!app.request_selected_session_preview(&mut ui_state));
assert_eq!(
app.active_session_preview.as_ref().unwrap().session_id,
"one"
);
assert_eq!(app.queued_session_preview.as_deref(), Some("three"));
}
#[test]
fn pending_session_switch_blocks_submit_new_prune_and_second_switch() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.pending_session_switch = Some(PendingSessionSwitch {
request_id: 1,
session_id: "target".to_string(),
generation: 0,
});
let mut ui_state = state::MissionControlState::default();
assert!(!app.submit("hello".to_string(), &mut ui_state, &receiver, test_area()));
assert_eq!(ui_state.status, "session switch loading; action blocked");
assert!(!app.submit("/new".to_string(), &mut ui_state, &receiver, test_area()));
assert_eq!(ui_state.status, "session switch loading; action blocked");
assert!(!app.submit(
"/prune-sessions 1".to_string(),
&mut ui_state,
&receiver,
test_area()
));
assert_eq!(ui_state.status, "session switch loading; action blocked");
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let _ = app.apply_input_action(
input::InputAction::SelectSession("other".to_string()).into(),
&mut ui_state,
&mut context,
);
assert_eq!(ui_state.status, "session switch already loading");
}
#[test]
fn session_switch_result_applies_snapshot_and_preserves_critical_events() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let active = manager.open("active-session").unwrap();
let target = manager.open("target-session").unwrap();
target
.append(&crate::sessions::SessionEvent::new(
"user_input",
target.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"target prompt"}),
))
.unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender.clone());
app.state.session_manager = manager;
app.state.current_session = Some(active.clone());
app.pending_session_switch = Some(PendingSessionSwitch {
request_id: 7,
session_id: target.id().to_string(),
generation: app.session_generation,
});
let loaded = start_session_switch_load(
&app.state.session_manager,
app.state.config.as_ref(),
target.id(),
)
.unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let mut ui_state = state::MissionControlState {
transcript: vec!["old".to_string()].into(),
..Default::default()
};
let mut result = DrainResult::default();
result
.session_switch_loaded
.push((7, target.id().to_string(), Ok(loaded)));
assert!(app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert_eq!(app.state.active_session_id(), Some(target.id()));
assert_eq!(ui_state.transcript, vec!["you: target prompt".to_string()]);
assert!(matches!(
receiver.try_recv(),
Ok(TuiEvent::RunFinished { final_event: None })
));
}
#[test]
fn session_switch_result_ignored_after_generation_change() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let active = manager.open("active-session").unwrap();
let target = manager.open("target-session").unwrap();
target
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
target.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
))
.unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.session_manager = manager;
app.state.current_session = Some(active.clone());
app.pending_session_switch = Some(PendingSessionSwitch {
request_id: 3,
session_id: target.id().to_string(),
generation: 0,
});
app.session_generation = 1;
let loaded = start_session_switch_load(
&app.state.session_manager,
app.state.config.as_ref(),
target.id(),
)
.unwrap();
let mut ui_state = state::MissionControlState {
transcript: vec!["keep".to_string()].into(),
..Default::default()
};
let mut result = DrainResult::default();
result
.session_switch_loaded
.push((3, target.id().to_string(), Ok(loaded)));
assert!(app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert_eq!(app.state.active_session_id(), Some(active.id()));
assert_eq!(ui_state.transcript, vec!["keep".to_string()]);
}
#[test]
fn footer_branch_event_updates_branch_and_ignores_stale() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.pending_footer_git_branch_request_id = Some(2);
let mut ui_state = state::MissionControlState::default();
let mut stale = DrainResult::default();
stale
.footer_git_branch_loaded
.push((1, Some("old".to_string())));
assert!(!app.handle_footer_git_branch_drain(&mut ui_state, &stale));
assert!(ui_state.footer_git_branch.is_none());
let mut current = DrainResult::default();
current
.footer_git_branch_loaded
.push((2, Some("main".to_string())));
assert!(app.handle_footer_git_branch_drain(&mut ui_state, ¤t));
assert_eq!(ui_state.footer_git_branch.as_deref(), Some("main"));
}
#[test]
fn paste_burst_bare_enter_while_prompt_focused_is_newline() {
let now = Instant::now();
let state = state_with_last_key(now);
assert!(should_treat_enter_as_paste_newline(
&state,
&enter_key(),
now,
PASTE_BURST_THRESHOLD_MS
));
}
#[test]
fn paste_burst_enter_at_threshold_is_not_newline() {
let now = Instant::now();
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
last_key_event_at: Some(now - Duration::from_millis(PASTE_BURST_THRESHOLD_MS as u64)),
..Default::default()
};
assert!(!should_treat_enter_as_paste_newline(
&state,
&enter_key(),
now,
PASTE_BURST_THRESHOLD_MS
));
}
#[test]
fn release_event_does_not_prime_next_enter_as_paste_burst() {
let now = Instant::now();
let release_key = crossterm::event::KeyEvent::new_with_kind(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
crossterm::event::KeyEventKind::Release,
);
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
last_key_event_at: should_record_key_event_at(&release_key).then_some(now),
..Default::default()
};
assert!(!should_treat_enter_as_paste_newline(
&state,
&enter_key(),
now + Duration::from_millis(1),
PASTE_BURST_THRESHOLD_MS
));
}
#[test]
fn app_loop_draws_between_budgeted_chunks_under_full_receiver() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for _ in 0..4 {
sender.send(TuiEvent::Done).unwrap();
}
let mut ui_state = state::MissionControlState::default();
let mut redraw_intent = RedrawIntent::None;
let mut budget_state = DrainLoopBudgetState::default();
let outcome = drain_tui_events_budgeted(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 2,
max_elapsed: Duration::from_secs(1),
},
);
budget_state.apply_outcome(&outcome, &mut redraw_intent);
assert!(outcome.exhausted);
assert_eq!(receiver.len(), 2);
assert!(!budget_state.should_drain_at_loop_top());
assert!(should_draw_frame(redraw_intent));
assert!(budget_state.after_draw());
assert!(budget_state.should_drain_at_loop_top());
}
#[test]
fn reducer_exit_returns_exit_and_joins_without_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result =
app.apply_input_action(input::InputAction::Exit.into(), &mut ui_state, &mut context);
assert!(matches!(result, actions::AppControlFlow::Exit));
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn reducer_open_set_model_opens_picker_when_idle() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result = app.apply_input_action(
input::InputAction::OpenSetModel.into(),
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(app.model_catalog_loading);
assert!(ui_state.pending_model_catalog_request_id.is_some());
assert_eq!(ui_state.status, "loading model catalogs…");
}
#[test]
fn reducer_open_set_model_rejects_while_active_run() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState::default();
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result = app.apply_input_action(
input::InputAction::OpenSetModel.into(),
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(!app.model_catalog_loading);
assert!(ui_state.pending_model_catalog_request_id.is_none());
assert_eq!(
ui_state.status,
"cannot change model while a prompt is running"
);
}
#[test]
fn reducer_cancel_login_clears_custom_provider_setup() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.start_custom_provider_setup();
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result = app.apply_input_action(
input::InputAction::CancelLogin.into(),
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(ui_state.modals.custom_provider_setup.is_none());
assert_eq!(
ui_state.status,
"custom provider setup cancelled; settings unchanged"
);
}
#[test]
fn reducer_cancel_custom_provider_replacement_preserves_status() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.start_custom_provider_setup();
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result = app.apply_input_action(
input::InputAction::CancelCustomProviderReplacement.into(),
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(ui_state.modals.custom_provider_setup.is_none());
assert_eq!(
ui_state.status,
"custom provider replacement cancelled; existing provider unchanged"
);
}
#[test]
fn reducer_set_skill_enabled_refreshes_skill_autocomplete_candidates() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let skill = crate::skills::Skill {
name: "demo".to_string(),
path: temp.path().join("demo/SKILL.md"),
frontmatter: std::collections::BTreeMap::new(),
body: String::new(),
};
app.discovered_skills
.skills
.insert("demo".to_string(), skill.clone());
app.skills.skills.insert("demo".to_string(), skill);
let mut ui_state = state::MissionControlState::default();
ui_state.open_skills_modal(
vec![state::SkillToggleRow {
name: "demo".to_string(),
source: "test".to_string(),
enabled: true,
}],
5,
);
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let result = app.apply_input_action(
input::InputAction::SetSkillEnabled {
name: "demo".to_string(),
enabled: true,
}
.into(),
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(autocomplete_candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::SkillTag && candidate.name == "demo"
}));
}
fn catalog(
entries: Vec<crate::model_catalog::ModelCatalogEntry>,
) -> crate::model_catalog::CatalogForUi {
crate::model_catalog::CatalogForUi {
entries,
stale: false,
notice: None,
}
}
#[test]
fn open_models_modal_with_cached_catalog_shows_rows_immediately() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.model_catalog_cache = Some(catalog(vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-a"),
]));
let mut ui_state = state::MissionControlState::default();
app.open_models_modal(&mut ui_state, test_area());
let modal = ui_state.modals.models_modal.as_ref().unwrap();
assert!(!modal.loading);
assert_eq!(modal.rows[0].id, "openai-codex/gpt-a");
assert_eq!(ui_state.status, "showing models: global settings");
}
#[test]
fn open_models_modal_without_catalog_shows_loading() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.open_models_modal(&mut ui_state, test_area());
let modal = ui_state.modals.models_modal.as_ref().unwrap();
assert!(modal.loading);
assert!(modal.rows.is_empty());
assert!(app.model_catalog_loading);
assert!(matches!(
app.pending_model_catalog_consumer,
Some(PendingModelCatalogConsumer::ModelsModal)
));
assert_eq!(ui_state.status, "loading models");
}
#[test]
fn toggle_models_scope_rebuilds_rows_for_new_scope() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.model_catalog_cache = Some(catalog(vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-a"),
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-b"),
]));
std::fs::create_dir_all(temp.path().join("project")).unwrap();
crate::config::set_model_disabled_for_scope(
&app.config.paths,
crate::config::SettingsScope::Project,
"openai-codex/gpt-b",
true,
)
.unwrap();
let mut ui_state = state::MissionControlState::default();
app.open_models_modal(&mut ui_state, test_area());
app.toggle_models_settings_scope(&mut ui_state, test_area());
let modal = ui_state.modals.models_modal.as_ref().unwrap();
assert_eq!(modal.settings_scope, crate::config::SettingsScope::Project);
assert!(
modal
.rows
.iter()
.find(|row| row.id == "openai-codex/gpt-a")
.unwrap()
.enabled
);
assert!(
!modal
.rows
.iter()
.find(|row| row.id == "openai-codex/gpt-b")
.unwrap()
.enabled
);
}
#[test]
fn persist_model_enable_disable_writes_selected_scope_settings() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
std::fs::create_dir_all(temp.path().join("project")).unwrap();
let mut ui_state = state::MissionControlState::default();
ui_state.open_models_modal_scoped(
vec![state::ModelToggleRow {
id: "openai-codex/gpt-a".to_string(),
provider: "openai-codex".to_string(),
display_name: "gpt-a".to_string(),
enabled: false,
}],
5,
crate::config::SettingsScope::Project,
"Project".to_string(),
false,
);
app.set_model_enabled("openai-codex/gpt-a", false, &mut ui_state);
let global = crate::config::read_settings_for_scope(
&app.config.paths,
crate::config::SettingsScope::Global,
)
.unwrap_or_default();
let project = crate::config::read_settings_for_scope(
&app.config.paths,
crate::config::SettingsScope::Project,
)
.unwrap_or_default();
assert!(global.models.disabled.is_empty());
assert_eq!(project.models.disabled, vec!["openai-codex/gpt-a"]);
assert!(ui_state.status.contains("project settings"));
}
#[test]
fn catalog_event_populates_open_models_modal_and_closed_modal_is_ignored() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.open_models_modal_scoped(
Vec::new(),
5,
crate::config::SettingsScope::Global,
"Global".to_string(),
true,
);
app.pending_model_catalog_consumer = Some(PendingModelCatalogConsumer::ModelsModal);
let mut drain = DrainResult::default();
ui_state.provider = crate::providers::OPENAI_CODEX_PROVIDER.to_string();
ui_state.model = "gpt-a".to_string();
let mut enriched = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-a");
enriched.supports_reasoning = Some(true);
drain
.model_catalog_loaded
.push((1, Ok(catalog(vec![enriched])), 5));
assert!(app.handle_model_catalog_drain(&mut ui_state, &drain));
let modal = ui_state.modals.models_modal.as_ref().unwrap();
assert!(!modal.loading);
assert_eq!(modal.rows[0].id, "openai-codex/gpt-a");
assert!(ui_state.thinking_levels.len() > 1);
ui_state.close_models_modal();
app.pending_model_catalog_consumer = Some(PendingModelCatalogConsumer::ModelsModal);
let mut drain = DrainResult::default();
drain.model_catalog_loaded.push((
2,
Ok(catalog(vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-b"),
])),
5,
));
assert!(app.handle_model_catalog_drain(&mut ui_state, &drain));
assert!(!ui_state.models_modal_visible());
assert_eq!(
app.model_catalog_cache.as_ref().unwrap().entries[0].id,
"openai-codex/gpt-b"
);
}
}