use super::super::*;
use super::{MissionControlApp, actions};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PendingStartupPrompt {
pub(super) prompt: String,
pub(super) painted: bool,
pub(super) initial_candidate: bool,
}
const DECORATIVE_STARTUP_JOIN_GRACE_PERIOD: Duration = Duration::from_millis(50);
fn join_decorative_startup_worker(worker: JoinHandle<()>) -> bool {
let deadline = Instant::now() + DECORATIVE_STARTUP_JOIN_GRACE_PERIOD;
while !worker.is_finished() {
if Instant::now() >= deadline {
drop(worker);
return false;
}
thread::yield_now();
}
worker.join().is_err()
}
fn join_critical_or_provider_worker(worker: JoinHandle<()>, worker_name: &str) -> Option<String> {
let deadline = Instant::now() + WORKER_EXIT_JOIN_TIMEOUT;
while !worker.is_finished() {
let now = Instant::now();
if now >= deadline {
if worker.is_finished() {
break;
}
drop(worker);
return Some(format!(
"{worker_name} did not stop within {}ms; detached",
WORKER_EXIT_JOIN_TIMEOUT.as_millis()
));
}
thread::sleep((deadline - now).min(Duration::from_millis(1)));
}
worker
.join()
.err()
.map(|_| format!("{worker_name} panicked"))
}
fn join_provider_worker(worker: WorkerState) -> Option<String> {
match worker.shutdown_policy {
WorkerShutdownPolicy::Cancel => {
join_critical_or_provider_worker(worker.handle, "provider worker")
}
WorkerShutdownPolicy::WaitForCompletion => worker
.handle
.join()
.err()
.map(|_| "provider worker panicked".to_string()),
}
}
pub(super) fn initial_prompt_text_and_cursor(prompt: Option<&str>) -> (String, usize) {
let text = prompt.unwrap_or_default().to_string();
let cursor = text.len();
(text, cursor)
}
pub(super) fn draw_initial_frame<B>(
terminal: &mut ratatui::Terminal<B>,
state: &mut state::MissionControlState,
) -> Result<ratatui::layout::Rect>
where
B: ratatui::backend::Backend,
B::Error: Send + Sync + 'static,
{
let mut area = ratatui::layout::Rect::default();
terminal.draw(|frame| {
area = frame.area();
render::draw(frame, state);
})?;
Ok(area)
}
fn bounded_startup_diagnostic(message: impl std::fmt::Display) -> String {
const MAX_STARTUP_DIAGNOSTIC_CHARS: usize = 512;
let sanitized = crate::output::sanitize_display_text(&message.to_string());
let normalized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
let mut bounded = normalized
.chars()
.take(MAX_STARTUP_DIAGNOSTIC_CHARS)
.collect::<String>();
if normalized.chars().count() > MAX_STARTUP_DIAGNOSTIC_CHARS {
bounded.pop();
bounded.push('…');
}
bounded
}
fn startup_failure_message(message: impl std::fmt::Display) -> String {
format!(
"Mission Control startup failed: {}",
bounded_startup_diagnostic(message)
)
}
fn enabled_subagent_diagnostic(
diagnostic: &crate::subagents::profiles::SubagentProfileDiagnostic,
disabled: &std::collections::HashSet<String>,
) -> bool {
diagnostic.id.as_deref().is_some_and(|id| {
crate::subagents::profiles::validate_subagent_identity_id(id).is_ok()
&& !disabled.contains(id)
})
}
fn load_startup_critical_data(
settings: crate::config::Settings,
discovered_skills: SkillDiscovery,
paths: crate::config::McPaths,
cancellation: &crate::cancellation::AgentCancellation,
) -> anyhow::Result<TuiStartupCritical> {
let disabled_tools = crate::config::disabled_tool_names_from_settings(&settings)
.into_iter()
.collect::<std::collections::HashSet<_>>();
let disabled_subagent_profiles =
crate::config::disabled_subagent_profile_names_from_settings(&settings)
.into_iter()
.collect::<std::collections::HashSet<_>>();
let skills = filter_enabled_skills(
&discovered_skills,
&crate::config::disabled_skill_names_from_settings(&settings),
);
let subagent_profile_discovery =
crate::subagents::profiles::discover_subagent_profiles(&paths.subagents);
if let Some(diagnostic) = subagent_profile_discovery
.diagnostics
.iter()
.find(|diagnostic| enabled_subagent_diagnostic(diagnostic, &disabled_subagent_profiles))
{
let id = diagnostic.id.as_deref().unwrap_or("<unknown>");
anyhow::bail!(
"enabled subagent profile '{}' failed discovery: {}",
bounded_startup_diagnostic(id),
bounded_startup_diagnostic(&diagnostic.message)
);
}
let primary_agent_discovery =
crate::primary_agents::discover_primary_agent_profiles(&paths.primary_agents);
let persisted_resolution = resolve_persisted_primary_agent_selection(
settings.selected_primary_agent.as_deref(),
&primary_agent_discovery.profiles,
);
if settings.selected_primary_agent.is_some() && persisted_resolution.selected_id.is_none() {
let detail = persisted_resolution
.diagnostics
.first()
.map(|diagnostic| diagnostic.message.as_str())
.unwrap_or("stored primary agent selection is unavailable");
anyhow::bail!(
"stored primary agent selection is unavailable: {}",
bounded_startup_diagnostic(detail)
);
}
let mcp = (!settings.mcp_servers.is_empty())
.then(|| {
crate::mcp::manager::McpManager::from_settings_strict_cancellable(
&settings.mcp_servers,
Some(&paths.root),
cancellation,
)
.map(|manager| std::sync::Arc::new(std::sync::Mutex::new(manager)))
})
.transpose()?;
Ok(TuiStartupCritical {
disabled_tools,
disabled_subagent_profiles,
skills,
subagent_profile_discovery,
primary_agent_discovery,
selected_primary_agent: persisted_resolution.selected_id,
subagent_card_rows: settings.tui.subagent_card_rows(),
mcp,
})
}
fn load_startup_decorative_data(
config: EffectiveConfig,
manager: SessionManager,
session_snapshot: (Option<String>, u64),
cwd: PathBuf,
skills: SkillDiscovery,
model: String,
provider: String,
) -> TuiStartupDecorative {
let (active_session_id, session_generation) = session_snapshot;
let mut warning = None;
let active_latest_title = active_session_id.as_deref().and_then(|active_id| {
match manager.list_metadata_summaries() {
Ok(summaries) => summaries
.into_iter()
.find(|summary| summary.session.id() == active_id)
.and_then(|summary| summary.latest_title),
Err(error) => {
warning = Some(bounded_startup_diagnostic(format!(
"could not load active session title metadata: {error}"
)));
None
}
}
});
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 context_max_tokens = crate::config::load_context_budget(&config)
.map(|budget| budget.max_tokens)
.unwrap_or_else(|_| crate::context::ContextBudget::default().max_tokens);
let fast_mode_service_tier = (provider == crate::providers::OPENAI_CODEX_PROVIDER)
.then(|| crate::model_catalog::cached_codex_fast_service_tier(&config.paths, &model))
.flatten();
let footer_git_branch = crate::tui::sessions::commands::current_git_branch(&cwd);
TuiStartupDecorative {
active_session_id,
session_generation,
active_latest_title,
file_autocomplete_candidates,
skill_autocomplete_candidates,
thinking_levels,
context_max_tokens,
footer_git_branch,
provider,
model,
fast_mode_service_tier,
warning,
}
}
impl MissionControlApp {
pub(super) 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;
self.startup_cancel.store(false, Ordering::SeqCst);
self.startup_critical_event_sent
.store(false, Ordering::SeqCst);
let critical_sender = self.events.clone();
let critical_settings = self.settings.clone();
let critical_skills = self.discovered_skills.clone();
let critical_paths = self.config.paths.clone();
let critical_cancel = Arc::clone(&self.startup_cancel);
let critical_event_sent = Arc::clone(&self.startup_critical_event_sent);
self.startup_critical_worker = Some(thread::spawn(move || {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let cancellation = crate::cancellation::AgentCancellation::new(critical_cancel);
load_startup_critical_data(
critical_settings,
critical_skills,
critical_paths,
&cancellation,
)
}))
.map_err(|_| "critical startup worker panicked".to_string())
.and_then(|result| result.map_err(bounded_startup_diagnostic));
let sent = send_tui_event(
&critical_sender,
TuiEvent::StartupCriticalLoaded {
request_id,
result: Box::new(result),
},
)
.is_ok();
critical_event_sent.store(sent, Ordering::SeqCst);
}));
let decorative_sender = self.events.clone();
let decorative_config = self.config.clone();
let decorative_manager = self.state.session_manager.clone();
let decorative_active_session_id = self.state.active_session_id().map(ToOwned::to_owned);
let decorative_session_generation = self.session_generation;
let decorative_cwd = self.state.cwd.clone();
let decorative_skills = self.skills.clone();
let decorative_model = self.state.model.clone();
let decorative_provider = self.state.auth_state.provider().to_string();
let decorative_cancel = Arc::clone(&self.startup_cancel);
self.startup_decorative_worker = Some(thread::spawn(move || {
if decorative_cancel.load(Ordering::SeqCst) {
return;
}
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
load_startup_decorative_data(
decorative_config,
decorative_manager,
(decorative_active_session_id, decorative_session_generation),
decorative_cwd,
decorative_skills,
decorative_model,
decorative_provider,
)
}))
.map_err(|_| "decorative startup worker panicked".to_string());
if decorative_cancel.load(Ordering::SeqCst) {
return;
}
let _ = send_tui_event(
&decorative_sender,
TuiEvent::StartupDecorativeLoaded {
request_id,
result: Box::new(result),
},
);
}));
request_id
}
pub(crate) fn cleanup_after_run(&mut self) -> anyhow::Result<()> {
self.summarizer.shutdown();
self.startup_cancel.store(true, Ordering::SeqCst);
self.fast_mode_persistence.request_shutdown();
if let Some(worker) = self.worker.as_ref()
&& worker.shutdown_policy == WorkerShutdownPolicy::Cancel
{
worker.cancel.store(true, Ordering::SeqCst);
worker.steering.clear();
}
self.cancel_rewind_workers();
self.cancel_model_selection_workers();
self.cancel_export_workers();
let theme_worker_errors = self.join_theme_workers_for_cleanup();
let rewind_worker_errors = self.join_rewind_workers_for_cleanup();
let export_worker_errors = self.join_export_workers_for_cleanup();
let model_selection_worker_errors = self.join_model_selection_workers_for_cleanup();
let critical_worker = self.startup_critical_worker.take();
let decorative_worker = self.startup_decorative_worker.take();
let provider_worker = self.worker.take();
let mut errors = theme_worker_errors;
errors.extend(rewind_worker_errors);
errors.extend(model_selection_worker_errors);
errors.extend(export_worker_errors);
errors.extend(self.fast_mode_persistence.cleanup());
errors.extend(self.settings_persistence.cleanup());
errors.extend(self.session_maintenance.cleanup());
errors.extend(self.system_prompt.cleanup());
if let Some(worker) = self.footer_git_branch_worker.take() {
let deadline = Instant::now() + WORKER_EXIT_JOIN_TIMEOUT;
while !worker.is_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
if worker.is_finished() {
if let Err(error) = worker.join() {
errors.push(format!("footer branch worker: {error}"));
}
} else {
errors.push("footer branch worker did not finish before shutdown".into());
}
}
self.pending_footer_git_branch_request_id = None;
if let Some(worker) = critical_worker
&& let Some(error) = join_critical_or_provider_worker(worker, "critical startup worker")
{
errors.push(error);
}
if let Some(worker) = decorative_worker
&& join_decorative_startup_worker(worker)
{
errors.push("decorative startup worker panicked".to_string());
}
if let Some(worker) = provider_worker
&& let Some(error) = join_provider_worker(worker)
{
errors.push(error);
}
self.active_run = false;
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!(errors.join("; ")))
}
}
fn status_with_initial_prompt_rejection(&self, status: String) -> String {
if self.initial_prompt_rejected {
if status == "Mission Control ready" {
state::PROMPT_TOO_LARGE_STATUS.to_string()
} else {
format!("{status}; {}", state::PROMPT_TOO_LARGE_STATUS)
}
} else {
status
}
}
pub(super) fn startup_initial_status(&self) -> String {
if self.initial_prompt_rejected {
state::PROMPT_TOO_LARGE_STATUS.to_string()
} else {
"starting Mission Control…".to_string()
}
}
fn set_ready_status(&self, ui_state: &mut state::MissionControlState) {
let status = match &self.startup_warning {
Some(warning) => format!("Mission Control ready; startup warning: {warning}"),
None => "Mission Control ready".to_string(),
};
ui_state.status = self.status_with_initial_prompt_rejection(status);
}
fn fail_startup(
&mut self,
ui_state: &mut state::MissionControlState,
error: impl std::fmt::Display,
) {
if self.startup_readiness == StartupReadiness::Failed {
return;
}
let message = startup_failure_message(error);
self.startup_readiness = StartupReadiness::Failed;
self.startup_failure = Some(message.clone());
self.clear_pending_startup_prompt();
ui_state.clear_prompt_input();
ui_state.status = self.status_with_initial_prompt_rejection(message);
ui_state.startup_prompt_queued = false;
}
pub(super) fn check_startup_worker_health(
&mut self,
ui_state: &mut state::MissionControlState,
) {
let finished = self
.startup_critical_worker
.as_ref()
.is_some_and(|worker| worker.is_finished());
if finished
&& self.startup_readiness == StartupReadiness::Loading
&& !self.startup_critical_event_sent.load(Ordering::SeqCst)
{
self.fail_startup(
ui_state,
"critical startup worker exited without publishing a readiness result",
);
}
}
fn apply_startup_critical(
&mut self,
ui_state: &mut state::MissionControlState,
loaded: TuiStartupCritical,
) -> anyhow::Result<()> {
{
let mut disabled_tools = self
.disabled_tools
.lock()
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))?;
*disabled_tools = loaded.disabled_tools.clone();
}
{
let mut disabled_subagents = self
.disabled_subagent_profiles
.lock()
.map_err(|_| anyhow::anyhow!("disabled subagent profiles lock poisoned"))?;
*disabled_subagents = loaded.disabled_subagent_profiles.clone();
}
let subagent_warnings = loaded
.subagent_profile_discovery
.diagnostics
.iter()
.filter(|diagnostic| {
!enabled_subagent_diagnostic(diagnostic, &loaded.disabled_subagent_profiles)
})
.take(5)
.map(|diagnostic| {
format!(
"subagent profile skipped: profile={} reason={}",
bounded_startup_diagnostic(
diagnostic
.id
.as_deref()
.or_else(|| diagnostic.path.as_deref().and_then(|path| path.to_str()))
.unwrap_or("<unknown>")
),
bounded_startup_diagnostic(&diagnostic.message)
)
})
.collect::<Vec<_>>();
self.skills = loaded.skills;
self.subagent_profile_discovery = loaded.subagent_profile_discovery;
self.mcp = loaded.mcp;
ui_state.subagent_card_rows = loaded.subagent_card_rows;
ui_state.invalidate_transcript_cache();
ui_state.set_primary_agents(
loaded
.primary_agent_discovery
.profiles
.into_values()
.map(Into::into)
.collect(),
);
ui_state.set_selected_primary_agent_id(loaded.selected_primary_agent.as_deref());
record_primary_agent_startup_diagnostics(
ui_state,
&loaded.primary_agent_discovery.diagnostics,
);
for warning in subagent_warnings {
ui_state.apply_output_event(&OutputEvent::Diagnostic {
level: "warning".to_string(),
message: warning,
});
}
self.startup_readiness = StartupReadiness::Ready;
self.set_ready_status(ui_state);
Ok(())
}
fn apply_startup_decorative(
&mut self,
ui_state: &mut state::MissionControlState,
loaded: TuiStartupDecorative,
autocomplete_candidates: &mut Vec<state::AutocompleteCandidate>,
) {
let startup_selection_is_current =
loaded.provider == ui_state.provider && loaded.model == ui_state.model;
let newer_catalog_has_current_selection =
self.model_catalog_cache.as_ref().is_some_and(|catalog| {
catalog.entries.iter().any(|entry| {
entry.provider == ui_state.provider && entry.model == ui_state.model
})
});
autocomplete_candidates.extend(loaded.file_autocomplete_candidates);
autocomplete_candidates.extend(loaded.skill_autocomplete_candidates);
if startup_selection_is_current && !newer_catalog_has_current_selection {
ui_state.refresh_thinking_levels(self.config.thinking_level, loaded.thinking_levels);
}
ui_state.update_context_window(loaded.context_max_tokens);
let active_session_id = self.state.active_session_id();
let startup_title_is_current = loaded.session_generation == self.session_generation
&& loaded.active_session_id.as_deref() == active_session_id
&& ui_state.footer_session_id.as_deref() == active_session_id
&& match active_session_id {
Some(session_id) => {
let short_id = crate::tui::sessions::commands::short_session_id(session_id);
ui_state.footer_session == short_id || ui_state.footer_session == session_id
}
None => ui_state.footer_session == "<disabled>",
};
if startup_title_is_current {
apply_footer_context(
ui_state,
self.state.current_session.as_ref(),
loaded.active_latest_title.as_deref(),
&self.state.cwd,
);
} else {
ui_state.footer_cwd = self.state.cwd.display().to_string();
}
ui_state.footer_git_branch = loaded.footer_git_branch;
if startup_selection_is_current {
self.refresh_fast_mode_state(ui_state);
}
if let Some(warning) = loaded.warning {
self.startup_warning = Some(bounded_startup_diagnostic(warning));
}
if self.startup_readiness == StartupReadiness::Ready {
self.set_ready_status(ui_state);
} else if let Some(warning) = &self.startup_warning {
ui_state.status = self.status_with_initial_prompt_rejection(format!(
"startup details warning: {warning}"
));
}
}
pub(super) fn handle_startup_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &mut DrainResult,
autocomplete_candidates: &mut Vec<state::AutocompleteCandidate>,
) -> bool {
let mut changed = false;
for (_request_id, loaded) in std::mem::take(&mut drain_result.startup_critical_loaded) {
match loaded {
Ok(loaded) => {
if let Err(error) = self.apply_startup_critical(ui_state, loaded) {
self.fail_startup(ui_state, error);
}
changed = true;
}
Err(error) => {
self.fail_startup(ui_state, error);
changed = true;
}
}
}
if self.startup_readiness == StartupReadiness::Failed {
return changed;
}
for (_request_id, loaded) in std::mem::take(&mut drain_result.startup_decorative_loaded) {
match loaded {
Ok(loaded) => {
self.apply_startup_decorative(ui_state, loaded, autocomplete_candidates)
}
Err(error) => {
self.startup_warning = Some(bounded_startup_diagnostic(error));
if self.startup_readiness == StartupReadiness::Ready {
self.set_ready_status(ui_state);
} else {
ui_state.status = self.status_with_initial_prompt_rejection(format!(
"startup details warning: {}",
self.startup_warning.as_deref().unwrap_or("unavailable")
));
}
}
}
changed = true;
}
changed
}
pub(super) fn initialize_startup_prompt_queue(&mut self, painted: bool) {
if self.pending_startup_prompt.is_none()
&& let Some(prompt) = self.pending_initial_prompt.clone()
{
self.pending_startup_prompt = Some(PendingStartupPrompt {
prompt,
painted,
initial_candidate: true,
});
}
}
pub(super) fn mark_pending_startup_prompt_painted(&mut self) {
if let Some(pending) = self.pending_startup_prompt.as_mut() {
pending.painted = true;
}
}
pub(super) fn clear_pending_startup_prompt(&mut self) {
self.pending_startup_prompt = None;
self.pending_initial_prompt = None;
self.initial_prompt_flush_ack = None;
}
pub(super) fn cancel_initial_startup_prompt(&mut self) {
let initial_candidate = self
.pending_startup_prompt
.as_ref()
.is_some_and(|pending| pending.initial_candidate);
if initial_candidate
|| (self.pending_startup_prompt.is_none() && self.pending_initial_prompt.is_some())
{
self.pending_startup_prompt = None;
self.pending_initial_prompt = None;
self.initial_prompt_flush_ack = None;
}
}
pub(super) fn sync_startup_prompt_signal(&self, ui_state: &mut state::MissionControlState) {
ui_state.startup_prompt_queued = self.pending_startup_prompt.is_some();
}
pub(super) fn mark_initial_prompt_input(
&mut self,
input_changed: bool,
action: Option<&input::InputAction>,
ui_state: &mut state::MissionControlState,
) {
let normal_prompt_submit = action.is_some_and(|action| {
let input::InputAction::Submit(prompt) = action else {
return false;
};
!prompt.trim().is_empty()
&& matches!(
input::submit_command::tui_submit_command(prompt, &self.commands),
input::submit_command::TuiSubmitCommand::None
)
});
let action_requires_new_flush = action.is_some_and(|action| {
matches!(
action,
input::InputAction::Submit(_)
| input::InputAction::CancelStartupPrompt
| input::InputAction::Exit
)
});
if input_changed || action_requires_new_flush {
self.initial_prompt_flush_ack = None;
}
if matches!(action, Some(input::InputAction::Exit)) {
self.clear_pending_startup_prompt();
self.sync_startup_prompt_signal(ui_state);
return;
}
let initial_candidate = self
.pending_startup_prompt
.as_ref()
.is_some_and(|pending| pending.initial_candidate)
|| (self.pending_startup_prompt.is_none() && self.pending_initial_prompt.is_some());
if initial_candidate
&& ((input_changed && !normal_prompt_submit)
|| action.is_some_and(|action| {
matches!(action, input::InputAction::Submit(_)) && !normal_prompt_submit
}))
{
self.cancel_initial_startup_prompt();
}
self.sync_startup_prompt_signal(ui_state);
}
pub(super) fn request_initial_prompt_flush(
&mut self,
input_events: &TerminalInputBridge,
) -> anyhow::Result<()> {
if self.pending_export.is_some() {
return Ok(());
}
if self.startup_readiness == StartupReadiness::Failed {
self.clear_pending_startup_prompt();
return Ok(());
}
self.initialize_startup_prompt_queue(true);
if self.startup_readiness == StartupReadiness::Ready
&& self
.pending_startup_prompt
.as_ref()
.is_some_and(|pending| pending.painted)
&& self.initial_prompt_flush_ack.is_none()
{
if input_events.reader_finished() {
return Err(anyhow::anyhow!(
"terminal input reader disconnected before startup prompt input fence"
));
}
self.initial_prompt_flush_ack = input_events.flush();
if self.initial_prompt_flush_ack.is_none() && input_events.reader_finished() {
return Err(anyhow::anyhow!(
"terminal input reader disconnected before startup prompt input fence"
));
}
}
Ok(())
}
fn submit_queued_startup_prompt(
&mut self,
prompt: String,
ui_state: &mut state::MissionControlState,
receiver: &Receiver<TuiEvent>,
terminal_area: ratatui::layout::Rect,
) -> bool {
self.startup_prompt_launch_in_progress = true;
let accepted = self.submit(prompt, ui_state, receiver, terminal_area);
self.startup_prompt_launch_in_progress = false;
accepted
}
pub(super) fn submit_initial_prompt_if_ready(
&mut self,
ui_state: &mut state::MissionControlState,
receiver: &Receiver<TuiEvent>,
terminal_area: ratatui::layout::Rect,
input_receiver: &Receiver<TerminalInputEvent>,
pending_input_events: &mut VecDeque<TerminalInputEvent>,
) -> anyhow::Result<Option<actions::AppControlFlow>> {
if self.startup_readiness == StartupReadiness::Failed {
self.clear_pending_startup_prompt();
self.sync_startup_prompt_signal(ui_state);
return Ok(None);
}
self.initialize_startup_prompt_queue(true);
self.sync_startup_prompt_signal(ui_state);
let Some(pending) = self.pending_startup_prompt.clone() else {
return Ok(None);
};
if self.startup_readiness == StartupReadiness::Loading {
if !pending.initial_candidate {
return Ok(None);
}
if !pending_input_events.is_empty() || !input_receiver.is_empty() {
return Ok(None);
}
if ui_state.prompt_content_revision() != 0 {
self.cancel_initial_startup_prompt();
self.sync_startup_prompt_signal(ui_state);
return Ok(None);
}
let command =
input::submit_command::tui_submit_command(&pending.prompt, &self.commands);
match command {
input::submit_command::TuiSubmitCommand::Quit => {
self.clear_pending_startup_prompt();
ui_state.clear_prompt_input();
self.sync_startup_prompt_signal(ui_state);
return Ok(Some(actions::AppControlFlow::Exit));
}
input::submit_command::TuiSubmitCommand::Help => {
self.clear_pending_startup_prompt();
ui_state.clear_prompt_input();
self.sync_startup_prompt_signal(ui_state);
let _ = self.submit(pending.prompt, ui_state, receiver, terminal_area);
}
input::submit_command::TuiSubmitCommand::None
if pending.prompt.trim().is_empty() =>
{
self.clear_pending_startup_prompt();
ui_state.clear_prompt_input();
self.sync_startup_prompt_signal(ui_state);
}
_ => {
let _ = self.submit(pending.prompt, ui_state, receiver, terminal_area);
}
}
self.sync_startup_prompt_signal(ui_state);
return Ok(Some(actions::AppControlFlow::Continue));
}
if !pending.painted {
return Ok(None);
}
if self.active_run
|| self.worker.is_some()
|| self.pending_session_switch.is_some()
|| self.pending_export.is_some()
{
return Ok(None);
}
let Some(flush_ack) = self.initial_prompt_flush_ack.as_ref() else {
return Ok(None);
};
match flush_ack.try_recv() {
Ok(()) => self.initial_prompt_flush_ack = None,
Err(crossbeam_channel::TryRecvError::Empty) => return Ok(None),
Err(crossbeam_channel::TryRecvError::Disconnected) => {
return Err(anyhow::anyhow!(
"terminal input reader disconnected while waiting for startup prompt input fence"
));
}
}
match input_receiver.try_recv() {
Ok(event) => pending_input_events.push_back(event),
Err(crossbeam_channel::TryRecvError::Empty) => {}
Err(crossbeam_channel::TryRecvError::Disconnected) => {
return Err(anyhow::anyhow!(
"terminal input reader disconnected while waiting for startup prompt input fence"
));
}
}
if !pending_input_events.is_empty() {
return Ok(None);
}
if pending.initial_candidate && ui_state.prompt_content_revision() != 0 {
self.cancel_initial_startup_prompt();
self.sync_startup_prompt_signal(ui_state);
return Ok(None);
}
let command = input::submit_command::tui_submit_command(&pending.prompt, &self.commands);
if matches!(command, input::submit_command::TuiSubmitCommand::Quit) {
self.clear_pending_startup_prompt();
if pending.initial_candidate {
ui_state.clear_prompt_input();
}
self.sync_startup_prompt_signal(ui_state);
return Ok(Some(actions::AppControlFlow::Exit));
}
let normal_prompt = !pending.prompt.trim().is_empty()
&& matches!(command, input::submit_command::TuiSubmitCommand::None);
if !normal_prompt {
self.clear_pending_startup_prompt();
if pending.initial_candidate {
ui_state.clear_prompt_input();
}
self.sync_startup_prompt_signal(ui_state);
let _ = self.submit(pending.prompt, ui_state, receiver, terminal_area);
return Ok(Some(actions::AppControlFlow::Continue));
}
let accepted = self.submit_queued_startup_prompt(
pending.prompt.clone(),
ui_state,
receiver,
terminal_area,
);
if accepted {
self.clear_pending_startup_prompt();
if pending.initial_candidate {
ui_state.clear_prompt_input();
}
self.sync_startup_prompt_signal(ui_state);
Ok(Some(actions::AppControlFlow::Continue))
} else {
self.sync_startup_prompt_signal(ui_state);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::super::tests::{test_app, test_app_with_initial_prompt, test_area, test_paths};
use super::*;
#[test]
fn startup_diagnostics_sanitize_controls_redact_secrets_and_bound() {
let secret = "startup-secret-value";
let message = format!(
"csi=\x1b[31mred\x1b[0m osc52=\x1b]52;c;{secret}\x07 title=\x1b]0;spoofed title\x1b\\ first\rforged\nsecond api_key={secret} {}",
"x".repeat(800)
);
let diagnostic = bounded_startup_diagnostic(&message);
assert!(!diagnostic.contains('\x1b'), "{diagnostic:?}");
assert!(!diagnostic.contains('\r'), "{diagnostic:?}");
assert!(!diagnostic.contains("52;c"), "{diagnostic:?}");
assert!(!diagnostic.contains("spoofed title"), "{diagnostic:?}");
assert!(!diagnostic.contains(secret), "{diagnostic:?}");
assert!(!diagnostic.contains('\n'), "{diagnostic:?}");
assert!(diagnostic.contains("first forged second"), "{diagnostic:?}");
assert!(
diagnostic.chars().all(|ch| !ch.is_control() || ch == '\t'),
"{diagnostic:?}"
);
assert!(diagnostic.chars().count() <= 512, "{diagnostic:?}");
assert!(diagnostic.ends_with("…"), "{diagnostic:?}");
let fatal = startup_failure_message(&message);
assert!(fatal.starts_with("Mission Control startup failed: "));
assert!(!fatal.contains('\x1b'), "{fatal:?}");
assert!(!fatal.contains('\r'), "{fatal:?}");
assert!(!fatal.contains(secret), "{fatal:?}");
assert!(!fatal.contains('\n'), "{fatal:?}");
}
#[test]
fn initial_prompt_is_normalized_once_before_editor_and_startup_queue() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let raw = "a\r\nb\rc\0\x1b\x7f\t\né";
let expected = "a\nb\nc\t\né";
let mut app = test_app_with_initial_prompt(&temp, sender.clone(), Some(raw.to_string()));
assert_eq!(app.pending_initial_prompt.as_deref(), Some(expected));
app.initialize_startup_prompt_queue(true);
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some(expected)
);
let (text, cursor) = initial_prompt_text_and_cursor(app.pending_initial_prompt.as_deref());
let editor = state::PromptEditor::from_canonical(&text, cursor);
assert_eq!(editor.text(), expected);
assert_eq!(editor.cursor(), expected.len());
let exact = "x".repeat(state::MAX_PROMPT_BYTES);
let mut exact_app = test_app_with_initial_prompt(&temp, sender, Some(exact.clone()));
assert_eq!(
exact_app.pending_initial_prompt.as_deref(),
Some(exact.as_str())
);
exact_app.initialize_startup_prompt_queue(true);
assert_eq!(
exact_app
.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.len()),
Some(state::MAX_PROMPT_BYTES)
);
}
#[test]
fn oversized_initial_prompt_is_empty_unqueued_and_reported_without_hiding_startup_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app_with_initial_prompt(
&temp,
sender,
Some("x".repeat(state::MAX_PROMPT_BYTES + 1)),
);
assert!(app.pending_initial_prompt.is_none());
assert!(app.initial_prompt_rejected);
app.initialize_startup_prompt_queue(true);
assert!(app.pending_startup_prompt.is_none());
let editor = state::PromptEditor::from_canonical("", 0);
assert!(editor.is_empty());
assert_eq!(app.startup_initial_status(), state::PROMPT_TOO_LARGE_STATUS);
let mut ui_state = state::MissionControlState::default();
app.set_ready_status(&mut ui_state);
assert_eq!(ui_state.status, state::PROMPT_TOO_LARGE_STATUS);
app.startup_warning = Some("decorative startup warning".to_string());
app.set_ready_status(&mut ui_state);
assert!(ui_state.status.contains(state::PROMPT_TOO_LARGE_STATUS));
assert!(ui_state.status.contains("decorative startup warning"));
app.startup_readiness = StartupReadiness::Loading;
app.fail_startup(&mut ui_state, "critical startup failure");
assert!(ui_state.status.contains("Mission Control startup failed"));
assert!(ui_state.status.contains(state::PROMPT_TOO_LARGE_STATUS));
}
fn loaded_critical(_temp: &tempfile::TempDir) -> TuiStartupCritical {
TuiStartupCritical {
disabled_tools: std::collections::HashSet::new(),
disabled_subagent_profiles: std::collections::HashSet::new(),
skills: SkillDiscovery::default(),
subagent_profile_discovery:
crate::subagents::profiles::SubagentProfileDiscovery::default(),
primary_agent_discovery: crate::primary_agents::PrimaryAgentProfileDiscovery::default(),
selected_primary_agent: None,
subagent_card_rows: crate::config::DEFAULT_TUI_SUBAGENT_CARD_ROWS,
mcp: None,
}
}
fn loaded_decorative(_temp: &tempfile::TempDir) -> TuiStartupDecorative {
TuiStartupDecorative {
active_session_id: None,
session_generation: 0,
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],
context_max_tokens: crate::context::ContextBudget::default().max_tokens,
footer_git_branch: Some("main".to_string()),
provider: "openai-codex".to_string(),
model: "gpt-5.5".to_string(),
fast_mode_service_tier: Some("priority".to_string()),
warning: None,
}
}
fn complete_initial_prompt_flush(app: &mut MissionControlApp) {
let (sender, receiver) = bounded(1);
sender.send(()).unwrap();
app.initial_prompt_flush_ack = Some(receiver);
}
#[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_critical_request_id: Some(2),
pending_startup_decorative_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::StartupCriticalLoaded {
request_id: 1,
result: Box::new(Ok(loaded_critical(&temp))),
},
&mut result,
);
assert!(result.startup_critical_loaded.is_empty());
assert_eq!(ui_state.status, "starting Mission Control…");
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupCriticalLoaded {
request_id: 2,
result: Box::new(Ok(loaded_critical(&temp))),
},
&mut result,
);
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupDecorativeLoaded {
request_id: 2,
result: Box::new(Ok(loaded_decorative(&temp))),
},
&mut result,
);
assert_eq!(result.startup_critical_loaded.len(), 1);
assert_eq!(result.startup_decorative_loaded.len(), 1);
assert!(app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates));
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert_eq!(ui_state.footer_git_branch.as_deref(), Some("main"));
assert!(
autocomplete_candidates
.iter()
.any(|candidate| candidate.name == "src/lib.rs")
);
}
#[test]
fn decorative_title_does_not_replace_live_title_update() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let active = manager.create().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());
let mut ui_state = state::MissionControlState {
footer_session: crate::tui::sessions::commands::short_session_id(active.id()),
footer_session_id: Some(active.id().to_string()),
pending_startup_decorative_request_id: Some(1),
..Default::default()
};
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::SessionTitleUpdated {
session_id: active.id().to_string(),
title: "Live title".to_string(),
},
&mut result,
);
let mut loaded = loaded_decorative(&temp);
loaded.active_session_id = Some(active.id().to_string());
loaded.session_generation = app.session_generation;
loaded.active_latest_title = Some("Stale startup title".to_string());
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupDecorativeLoaded {
request_id: 1,
result: Box::new(Ok(loaded)),
},
&mut result,
);
let mut autocomplete_candidates = Vec::new();
assert!(
app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates,)
);
assert_eq!(
ui_state.footer_session,
format!(
"Live title [{}]",
crate::tui::sessions::commands::short_session_id(active.id())
)
);
}
#[test]
fn decorative_title_from_previous_session_generation_is_ignored() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let initial = manager.create().unwrap();
let target = manager.create().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.session_manager = manager;
app.state.current_session = Some(initial.clone());
let mut loaded = loaded_decorative(&temp);
loaded.active_session_id = Some(initial.id().to_string());
loaded.session_generation = app.session_generation;
loaded.active_latest_title = Some("Old session title".to_string());
app.state.current_session = Some(target.clone());
app.session_generation = app.session_generation.saturating_add(1);
let mut ui_state = state::MissionControlState {
footer_session: crate::tui::sessions::commands::short_session_id(target.id()),
footer_session_id: Some(target.id().to_string()),
pending_startup_decorative_request_id: Some(1),
..Default::default()
};
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupDecorativeLoaded {
request_id: 1,
result: Box::new(Ok(loaded)),
},
&mut result,
);
let mut autocomplete_candidates = Vec::new();
assert!(
app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates,)
);
assert_eq!(
ui_state.footer_session,
crate::tui::sessions::commands::short_session_id(target.id())
);
}
#[test]
fn decorative_startup_result_does_not_gate_readiness_or_enter_transcript() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
pending_startup_decorative_request_id: Some(1),
status: "starting Mission Control…".to_string(),
..Default::default()
};
let mut loaded = loaded_decorative(&temp);
loaded.warning = Some("decorative warning: Bearer sk-secret".to_string());
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupDecorativeLoaded {
request_id: 1,
result: Box::new(Ok(loaded)),
},
&mut result,
);
let mut autocomplete_candidates = Vec::new();
assert!(
app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates,)
);
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
let transcript = ui_state.transcript_visual_text();
assert!(!transcript.contains("decorative warning"), "{transcript}");
assert!(!transcript.contains("sk-secret"), "{transcript}");
assert!(
app.startup_warning
.as_deref()
.is_some_and(|warning| !warning.contains("sk-secret"))
);
}
#[test]
fn stale_decorative_fast_mode_evidence_keeps_current_model_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.settings.fast.enabled = true;
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5.5".to_string(),
provider_ready: true,
fast_mode_enabled: true,
fast_mode_capable: true,
fast_mode_effective: true,
..Default::default()
};
let mut loaded = loaded_decorative(&temp);
loaded.model = "gpt-5.4".to_string();
loaded.fast_mode_service_tier = None;
let mut result = DrainResult::default();
result.startup_decorative_loaded.push((1, Ok(loaded)));
let mut autocomplete_candidates = Vec::new();
assert!(
app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates,)
);
assert!(ui_state.fast_mode_enabled);
assert!(ui_state.fast_mode_capable);
assert!(ui_state.fast_mode_effective);
}
#[test]
fn stale_decorative_reasoning_does_not_replace_newer_catalog_levels() {
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 {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5.4".to_string(),
..Default::default()
};
let fresh_levels = vec![
crate::thinking::ThinkingLevel::Default,
crate::thinking::ThinkingLevel::Low,
crate::thinking::ThinkingLevel::Medium,
crate::thinking::ThinkingLevel::High,
crate::thinking::ThinkingLevel::XHigh,
crate::thinking::ThinkingLevel::Max,
];
let mut fresh_entry = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-5.4");
fresh_entry.reasoning_efforts = Some(fresh_levels.clone());
let catalog = crate::model_catalog::CatalogForUi {
entries: vec![fresh_entry],
stale: false,
notice: None,
};
let mut drain_result = DrainResult::default();
drain_result.model_catalog_loaded.push((1, Ok(catalog), 5));
assert!(app.handle_model_catalog_drain(&mut ui_state, &drain_result));
assert_eq!(ui_state.thinking_levels, fresh_levels);
let mut loaded = loaded_decorative(&temp);
loaded.model = "gpt-5.4".to_string();
loaded.thinking_levels = crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec();
drain_result.startup_decorative_loaded.push((1, Ok(loaded)));
let mut autocomplete_candidates = Vec::new();
assert!(app.handle_startup_drain(
&mut ui_state,
&mut drain_result,
&mut autocomplete_candidates,
));
assert_eq!(ui_state.thinking_levels, fresh_levels);
}
#[test]
fn critical_readiness_allows_queued_prompt_while_decorative_worker_is_blocked() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_startup_prompt = Some(PendingStartupPrompt {
prompt: "queued prompt".to_string(),
painted: true,
initial_candidate: false,
});
let (release_sender, release_receiver) = bounded(1);
let decorative_started = Arc::new(AtomicBool::new(false));
let decorative_finished = Arc::new(AtomicBool::new(false));
let started = Arc::clone(&decorative_started);
let finished = Arc::clone(&decorative_finished);
app.startup_decorative_worker = Some(thread::spawn(move || {
started.store(true, Ordering::SeqCst);
let _ = release_receiver.recv();
finished.store(true, Ordering::SeqCst);
}));
while !decorative_started.load(Ordering::SeqCst) {
thread::yield_now();
}
let mut ui_state = state::MissionControlState {
pending_startup_critical_request_id: Some(1),
..Default::default()
};
let mut drain_result = DrainResult::default();
drain_result
.startup_critical_loaded
.push((1, Ok(loaded_critical(&temp))));
let mut autocomplete_candidates = Vec::new();
assert!(app.handle_startup_drain(
&mut ui_state,
&mut drain_result,
&mut autocomplete_candidates,
));
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert_eq!(ui_state.status, "Mission Control ready");
assert!(!decorative_finished.load(Ordering::SeqCst));
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(1);
let mut pending_input_events = VecDeque::new();
complete_initial_prompt_flush(&mut app);
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(app.active_run);
assert!(app.worker.is_some());
assert!(app.pending_startup_prompt.is_none());
app.cleanup_after_run().unwrap();
release_sender.send(()).unwrap();
while !decorative_finished.load(Ordering::SeqCst) {
thread::yield_now();
}
}
#[test]
fn finished_critical_worker_with_sent_event_is_not_false_failure() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let worker = thread::spawn(|| {});
while !worker.is_finished() {
thread::yield_now();
}
app.startup_critical_worker = Some(worker);
app.startup_critical_event_sent
.store(true, Ordering::SeqCst);
let mut ui_state = state::MissionControlState::default();
app.check_startup_worker_health(&mut ui_state);
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert!(app.startup_failure.is_none());
assert!(app.startup_critical_worker.is_some());
app.cleanup_after_run().unwrap();
assert!(app.startup_critical_worker.is_none());
}
#[test]
fn finished_critical_worker_without_sent_event_fails_startup() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let worker = thread::spawn(|| {});
while !worker.is_finished() {
thread::yield_now();
}
app.startup_critical_worker = Some(worker);
let mut ui_state = state::MissionControlState::default();
app.check_startup_worker_health(&mut ui_state);
assert_eq!(app.startup_readiness, StartupReadiness::Failed);
assert!(
app.startup_failure
.as_deref()
.is_some_and(|failure| failure.contains("without publishing"))
);
assert!(app.startup_critical_worker.is_some());
app.cleanup_after_run().unwrap();
assert!(app.startup_critical_worker.is_none());
}
#[test]
fn critical_startup_classifies_enabled_and_disabled_profile_diagnostics() {
let temp = tempfile::TempDir::new().unwrap();
let paths = test_paths(temp.path());
std::fs::create_dir_all(&paths.subagents).unwrap();
std::fs::write(paths.subagents.join("broken.md"), "not a profile").unwrap();
let error = load_startup_critical_data(
crate::config::Settings::default(),
SkillDiscovery::default(),
paths.clone(),
&crate::cancellation::AgentCancellation::default(),
)
.unwrap_err()
.to_string();
assert!(
error.contains("enabled subagent profile 'broken'"),
"{error}"
);
let mut settings = crate::config::Settings::default();
settings.subagents.disabled.push("broken".to_string());
std::fs::create_dir_all(&paths.primary_agents).unwrap();
std::fs::write(paths.primary_agents.join("unused.md"), "not a profile").unwrap();
let loaded = load_startup_critical_data(
settings,
SkillDiscovery::default(),
paths,
&crate::cancellation::AgentCancellation::default(),
)
.unwrap();
assert_eq!(loaded.subagent_profile_discovery.diagnostics.len(), 1);
assert_eq!(loaded.primary_agent_discovery.diagnostics.len(), 1);
}
#[test]
fn startup_loaded_applies_custom_subagent_card_rows() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut loaded = loaded_critical(&temp);
loaded.subagent_card_rows = 12;
let mut ui_state = state::MissionControlState {
pending_startup_critical_request_id: Some(1),
status: "starting Mission Control…".to_string(),
..Default::default()
};
let mut autocomplete_candidates = Vec::new();
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupCriticalLoaded {
request_id: 1,
result: Box::new(Ok(loaded)),
},
&mut result,
);
assert!(app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates));
assert_eq!(ui_state.subagent_card_rows, 12);
assert_eq!(ui_state.subagent_card_rows(), 12);
}
#[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_critical_request_id: Some(1),
prompt_editor: state::PromptEditor::new("typed before load", 5),
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
};
ui_state.open_connect_provider(Vec::new(), None);
ui_state.start_connect_custom_configuration();
let mut autocomplete_candidates = Vec::new();
let mut result = DrainResult::default();
apply_control_event_to_state(
&mut ui_state,
TuiEvent::StartupCriticalLoaded {
request_id: 1,
result: Box::new(Ok(loaded_critical(&temp))),
},
&mut result,
);
app.handle_startup_drain(&mut ui_state, &mut result, &mut autocomplete_candidates);
assert_eq!(ui_state.prompt_plain_text(), "typed before load");
assert_eq!(ui_state.prompt_cursor(), 5);
assert_eq!(ui_state.focus_pane, state::TuiFocusPane::ActivityTree);
assert!(ui_state.modals.connect_provider.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::StartupCriticalLoaded {
request_id: 1,
result: Box::new(Ok(loaded_critical(&tempfile::TempDir::new().unwrap()))),
})
.unwrap();
assert!(matches!(
receiver.try_recv(),
Ok(TuiEvent::StartupCriticalLoaded { request_id: 1, .. })
));
}
#[test]
fn initial_prompt_normal_queues_during_loading() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("initial".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("initial", "initial".len()),
..Default::default()
};
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(ui_state.prompt_plain_text().is_empty());
assert_eq!(app.pending_initial_prompt, None);
assert!(app.pending_startup_prompt.as_ref().is_some_and(|pending| {
pending.prompt == "initial" && !pending.initial_candidate && !pending.painted
}));
app.startup_readiness = StartupReadiness::Ready;
complete_initial_prompt_flush(&mut app);
ui_state.set_prompt_text("edited", "edited".len());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.pending_startup_prompt.is_some());
assert!(app.worker.is_none());
}
#[test]
fn initial_prompt_prefills_at_unicode_safe_end() {
let prompt = "Review café 🦀";
let (input, cursor) = initial_prompt_text_and_cursor(Some(prompt));
assert_eq!(input, prompt);
assert_eq!(cursor, prompt.len());
assert!(input.is_char_boundary(cursor));
let state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(&input, cursor, true),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
assert_eq!(state.prompt_plain_text(), prompt);
assert_eq!(state.prompt_cursor(), cursor);
}
#[test]
fn initial_help_prompt_is_immediate_while_loading() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("/help".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility("/help", "/help".len(), true),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert_eq!(ui_state.prompt_plain_text(), "");
assert_eq!(ui_state.prompt_cursor(), 0);
assert!(ui_state.show_help);
assert!(app.pending_initial_prompt.is_none());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert_eq!(ui_state.prompt_plain_text(), "");
assert!(ui_state.show_help);
}
#[test]
fn initial_quit_prompt_is_immediate_while_loading() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("/quit".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility("/quit", "/quit".len(), true),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Exit)
));
assert!(ui_state.prompt_plain_text().is_empty());
assert!(app.worker.is_none());
}
#[test]
fn initial_other_slash_prompt_is_blocked_and_preserved_while_loading() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("/login".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("/login", "/login".len()),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert_eq!(ui_state.prompt_plain_text(), "/login");
assert_eq!(app.pending_initial_prompt, None);
assert!(app.pending_startup_prompt.is_none());
assert_eq!(
ui_state.status,
"startup policy still loading; prompt execution disabled"
);
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn disconnected_startup_prompt_fence_is_fatal_without_clearing_or_launching() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.pending_startup_prompt = Some(PendingStartupPrompt {
prompt: "queued".to_string(),
painted: true,
initial_candidate: false,
});
let (ack_sender, ack_receiver) = bounded(1);
drop(ack_sender);
app.initial_prompt_flush_ack = Some(ack_receiver);
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("draft", 5),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let error = match app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
) {
Ok(_) => panic!("a disconnected startup fence must fail"),
Err(error) => error.to_string(),
};
assert!(
error.contains("terminal input reader disconnected"),
"{error}"
);
assert!(app.pending_startup_prompt.as_ref().is_some_and(|pending| {
pending.prompt == "queued" && pending.painted && !pending.initial_candidate
}));
assert_eq!(ui_state.prompt_plain_text(), "draft");
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn critical_startup_failure_clears_initial_prompt_and_blocks_auto_submit() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("initial".to_string());
let error = load_startup_critical_data(
crate::config::Settings {
selected_primary_agent: Some("missing".to_string()),
..Default::default()
},
app.discovered_skills.clone(),
app.config.paths.clone(),
&crate::cancellation::AgentCancellation::default(),
)
.unwrap_err()
.to_string();
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(
"initial",
"initial".len(),
true,
),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let mut drain_result = DrainResult::default();
drain_result.startup_critical_loaded.push((1, Err(error)));
let mut autocomplete_candidates = Vec::new();
assert!(app.handle_startup_drain(
&mut ui_state,
&mut drain_result,
&mut autocomplete_candidates,
));
assert_eq!(app.startup_readiness, StartupReadiness::Failed);
assert!(ui_state.prompt_plain_text().is_empty());
assert!(app.pending_initial_prompt.is_none());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(ui_state.prompt_plain_text().is_empty());
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn initial_prompt_edit_before_startup_cancels_auto_submit() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.pending_initial_prompt = Some("initial".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(
"initial",
"initial".len(),
true,
),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let prompt_revision_before = ui_state.prompt_content_revision();
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('!'),
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
app.mark_initial_prompt_input(
ui_state.prompt_content_revision() != prompt_revision_before,
Some(&action),
&mut ui_state,
);
assert_eq!(ui_state.prompt_plain_text(), "initial!");
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.pending_initial_prompt.is_none());
assert_eq!(ui_state.prompt_plain_text(), "initial!");
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn initial_prompt_clear_before_startup_cancels_auto_submit() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.pending_initial_prompt = Some("initial".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(
"initial",
"initial".len(),
true,
),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let prompt_revision_before = ui_state.prompt_content_revision();
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::CONTROL,
),
&mut ui_state,
);
app.mark_initial_prompt_input(
ui_state.prompt_content_revision() != prompt_revision_before,
Some(&action),
&mut ui_state,
);
assert!(ui_state.prompt_plain_text().is_empty());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.pending_initial_prompt.is_none());
assert!(ui_state.prompt_plain_text().is_empty());
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn queued_terminal_edit_wins_startup_boundary() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.pending_initial_prompt = Some("initial".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(
"initial",
"initial".len(),
true,
),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
input_sender
.send(TerminalInputEvent::Event(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
)))
.unwrap();
complete_initial_prompt_flush(&mut app);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert_eq!(app.pending_initial_prompt.as_deref(), Some("initial"));
let TerminalInputEvent::Event(crossterm::event::Event::Key(key)) =
pending_input_events.pop_front().unwrap()
else {
panic!("expected queued key event");
};
let prompt_revision_before = ui_state.prompt_content_revision();
let action = input::handle_key(key, &mut ui_state);
app.mark_initial_prompt_input(
ui_state.prompt_content_revision() != prompt_revision_before,
Some(&action),
&mut ui_state,
);
assert_eq!(ui_state.prompt_plain_text(), "initialx");
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.pending_initial_prompt.is_none());
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn startup_success_does_not_race_queued_terminal_input() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender.clone());
app.startup_readiness = StartupReadiness::Loading;
app.pending_initial_prompt = Some("initial".to_string());
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new_with_visibility(
"initial",
"initial".len(),
true,
),
focus_pane: state::TuiFocusPane::Prompt,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
input_sender
.send(TerminalInputEvent::Event(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
)))
.unwrap();
sender
.send(TuiEvent::StartupCriticalLoaded {
request_id: 1,
result: Box::new(Ok(loaded_critical(&temp))),
})
.unwrap();
let mut drain_result = drain_tui_events(&receiver, &mut ui_state);
let mut autocomplete_candidates = Vec::new();
assert!(app.handle_startup_drain(
&mut ui_state,
&mut drain_result,
&mut autocomplete_candidates,
));
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
complete_initial_prompt_flush(&mut app);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert_eq!(app.pending_initial_prompt.as_deref(), Some("initial"));
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn startup_input_fence_moves_one_event_per_turn_and_defers_launch() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState::default();
app.submit("queued".to_string(), &mut ui_state, &receiver, test_area());
app.mark_pending_startup_prompt_painted();
app.startup_readiness = StartupReadiness::Ready;
for _ in 0..3 {
input_sender
.send(TerminalInputEvent::Event(crossterm::event::Event::Resize(
100, 30,
)))
.unwrap();
}
for remaining in [2, 1, 0] {
complete_initial_prompt_flush(&mut app);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert_eq!(pending_input_events.len(), 1);
assert_eq!(input_receiver.len(), remaining);
assert!(app.pending_startup_prompt.is_some());
assert!(app.worker.is_none());
pending_input_events.pop_front();
}
assert!(pending_input_events.is_empty());
complete_initial_prompt_flush(&mut app);
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(app.pending_startup_prompt.is_none());
assert!(app.worker.is_some());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
app.cleanup_after_run().unwrap();
}
#[test]
fn normal_prompt_is_queued_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,
auth: None,
paths: test_paths(temp.path()),
},
settings: crate::config::Settings::default(),
appearance: crate::appearance::RuntimeAppearance::default(),
theme_cli_override: false,
instructions: Vec::new(),
initial_prompt: None,
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(),
herdr_reporter: None,
},
sender,
);
let mut ui_state = state::MissionControlState::default();
assert!(!app.submit("hello".to_string(), &mut ui_state, &receiver, test_area()));
assert!(ui_state.prompt_plain_text().is_empty());
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("hello")
);
assert_eq!(ui_state.status, "Prompt queued — waiting for startup");
assert!(!app.active_run);
}
#[test]
fn second_startup_enter_rejects_without_mutating_queued_prompt_or_draft() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("first", 5),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("first".to_string(), &mut ui_state, &receiver, test_area());
app.mark_pending_startup_prompt_painted();
ui_state.set_prompt_text("draft", 2);
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert!(matches!(action, input::InputAction::Submit(_)));
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = super::super::tests::action_context(
&receiver,
&mut autocomplete_candidates,
&mut clipboard,
);
app.apply_input_action(action, &mut ui_state, &mut context);
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("first")
);
assert_eq!(ui_state.prompt_cursor(), 2);
assert_eq!(ui_state.status, "startup prompt already queued");
}
#[test]
fn startup_alt_c_cancels_only_the_queued_prompt_and_preserves_draft() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("draft", 5),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("queued".to_string(), &mut ui_state, &receiver, test_area());
app.mark_pending_startup_prompt_painted();
ui_state.set_prompt_text("draft", "draft".len());
let mut autocomplete_candidates = Vec::new();
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut context = super::super::tests::action_context(
&receiver,
&mut autocomplete_candidates,
&mut clipboard,
);
app.apply_input_action(
input::InputAction::CancelStartupPrompt,
&mut ui_state,
&mut context,
);
assert!(app.pending_startup_prompt.is_none());
assert_eq!(ui_state.prompt_plain_text(), "draft");
assert_eq!(ui_state.status, "startup prompt cancelled");
assert!(ui_state.transcript.is_empty());
}
#[test]
fn startup_slash_commands_are_blocked_without_entering_the_queue() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("/login", 6),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("/login".to_string(), &mut ui_state, &receiver, test_area());
assert!(app.pending_startup_prompt.is_none());
assert_eq!(ui_state.prompt_plain_text(), "/login");
assert_eq!(
ui_state.status,
"startup policy still loading; prompt execution disabled"
);
assert!(ui_state.transcript.is_empty());
}
#[test]
fn queued_prompt_waits_for_paint_and_flush_then_launches_once() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("queued", 6),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("queued".to_string(), &mut ui_state, &receiver, test_area());
app.startup_readiness = StartupReadiness::Ready;
complete_initial_prompt_flush(&mut app);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.pending_startup_prompt.is_some());
app.mark_pending_startup_prompt_painted();
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(app.worker.is_some());
assert!(app.active_run);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
while app
.worker
.as_ref()
.is_some_and(|worker| !worker.handle.is_finished())
{
std::thread::yield_now();
}
assert!(drain_tui_events(&receiver, &mut ui_state).run_finished);
assert!(app.finish_worker_if_ready(&mut ui_state));
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(ui_state.status.starts_with("provider not configured for '"));
}
#[test]
fn launching_queued_prompt_preserves_a_draft_typed_after_capture() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("queued", 6),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("queued".to_string(), &mut ui_state, &receiver, test_area());
app.mark_pending_startup_prompt_painted();
ui_state.set_prompt_text("new draft", "new draft".len());
app.startup_readiness = StartupReadiness::Ready;
complete_initial_prompt_flush(&mut app);
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(app.worker.is_some());
assert!(app.active_run);
while app
.worker
.as_ref()
.is_some_and(|worker| !worker.handle.is_finished())
{
std::thread::yield_now();
}
assert!(drain_tui_events(&receiver, &mut ui_state).run_finished);
assert!(app.finish_worker_if_ready(&mut ui_state));
assert_eq!(ui_state.prompt_plain_text(), "new draft");
assert!(ui_state.running_prompt.is_none());
}
#[test]
fn initial_prompt_manual_enter_converts_candidate_without_duplicate_launch() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(4);
let mut pending_input_events = VecDeque::new();
let mut app = test_app(&temp, sender);
app.pending_initial_prompt = Some("initial".to_string());
app.startup_readiness = StartupReadiness::Loading;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("initial", 7),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
app.submit("initial".to_string(), &mut ui_state, &receiver, test_area());
assert_eq!(app.pending_initial_prompt, None);
assert!(
app.pending_startup_prompt.as_ref().is_some_and(|pending| {
pending.prompt == "initial" && !pending.initial_candidate
})
);
app.startup_readiness = StartupReadiness::Ready;
app.mark_pending_startup_prompt_painted();
complete_initial_prompt_flush(&mut app);
assert!(matches!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap(),
Some(actions::AppControlFlow::Continue)
));
assert!(app.pending_startup_prompt.is_none());
}
#[test]
fn cleanup_detaches_blocked_decorative_worker_within_bound() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let (release_sender, release_receiver) = bounded(1);
let started = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
let worker_started = Arc::clone(&started);
let worker_finished = Arc::clone(&finished);
app.startup_decorative_worker = Some(thread::spawn(move || {
worker_started.store(true, Ordering::SeqCst);
let _ = release_receiver.recv();
worker_finished.store(true, Ordering::SeqCst);
}));
while !started.load(Ordering::SeqCst) {
thread::yield_now();
}
let cleanup_started = Instant::now();
app.cleanup_after_run().unwrap();
assert!(
cleanup_started.elapsed()
< DECORATIVE_STARTUP_JOIN_GRACE_PERIOD + Duration::from_millis(250),
"decorative cleanup exceeded its bounded grace period"
);
assert!(!finished.load(Ordering::SeqCst));
assert!(app.startup_decorative_worker.is_none());
release_sender.send(()).unwrap();
while !finished.load(Ordering::SeqCst) {
thread::yield_now();
}
}
#[test]
fn cleanup_detaches_critical_worker_after_bounded_grace_and_reports_error() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let release = Arc::new(AtomicBool::new(false));
let started = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
let worker_release = Arc::clone(&release);
let worker_started = Arc::clone(&started);
let worker_finished = Arc::clone(&finished);
app.startup_critical_worker = Some(thread::spawn(move || {
worker_started.store(true, Ordering::SeqCst);
while !worker_release.load(Ordering::SeqCst) {
thread::yield_now();
}
worker_finished.store(true, Ordering::SeqCst);
}));
while !started.load(Ordering::SeqCst) {
thread::yield_now();
}
let cleanup_started = Instant::now();
let error = app.cleanup_after_run().unwrap_err().to_string();
assert!(
cleanup_started.elapsed() < WORKER_EXIT_JOIN_TIMEOUT + Duration::from_millis(250),
"critical cleanup exceeded its bounded grace period"
);
assert!(
error.contains("critical startup worker did not stop"),
"{error}"
);
assert!(error.contains("detached"), "{error}");
assert!(app.startup_cancel.load(Ordering::SeqCst));
assert!(app.startup_critical_worker.is_none());
assert!(!finished.load(Ordering::SeqCst));
release.store(true, Ordering::SeqCst);
while !finished.load(Ordering::SeqCst) {
thread::yield_now();
}
}
#[test]
fn cleanup_detaches_provider_worker_after_bounded_grace_and_reports_error() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let release = Arc::new(AtomicBool::new(false));
let started = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
let provider_cancel = Arc::new(AtomicBool::new(false));
let worker_release = Arc::clone(&release);
let worker_started = Arc::clone(&started);
let worker_finished = Arc::clone(&finished);
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
worker_started.store(true, Ordering::SeqCst);
while !worker_release.load(Ordering::SeqCst) {
thread::yield_now();
}
worker_finished.store(true, Ordering::SeqCst);
}),
cancel: Arc::clone(&provider_cancel),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
shutdown_policy: WorkerShutdownPolicy::Cancel,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
while !started.load(Ordering::SeqCst) {
thread::yield_now();
}
let cleanup_started = Instant::now();
let error = app.cleanup_after_run().unwrap_err().to_string();
assert!(
cleanup_started.elapsed() < WORKER_EXIT_JOIN_TIMEOUT + Duration::from_millis(250),
"provider cleanup exceeded its bounded grace period"
);
assert!(error.contains("provider worker did not stop"), "{error}");
assert!(error.contains("detached"), "{error}");
assert!(provider_cancel.load(Ordering::SeqCst));
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(!finished.load(Ordering::SeqCst));
release.store(true, Ordering::SeqCst);
while !finished.load(Ordering::SeqCst) {
thread::yield_now();
}
}
#[test]
fn loading_queued_prompt_input_reader_disconnect_is_fatal_before_cleanup() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_startup_prompt = Some(PendingStartupPrompt {
prompt: "queued prompt".to_string(),
painted: false,
initial_candidate: false,
});
let lifecycle = Arc::new(std::sync::Mutex::new(Vec::new()));
let run_lifecycle = Arc::clone(&lifecycle);
let cleanup_lifecycle = Arc::clone(&lifecycle);
let result = crate::tui::run_with_cleanup(
&mut app,
|app| {
let _terminal_guard =
crate::tui::terminal::test_guard_with_restore_probe(Arc::clone(&run_lifecycle))
.unwrap();
let critical_lifecycle = Arc::clone(&run_lifecycle);
let critical_cancel = Arc::clone(&app.startup_cancel);
app.startup_critical_worker = Some(thread::spawn(move || {
while !critical_cancel.load(Ordering::SeqCst) {
thread::yield_now();
}
critical_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.push("critical cleanup joined");
}));
assert!(app.pending_startup_prompt.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
Err(app.input_reader_disconnected_error())
},
|app| {
let terminal_restored = cleanup_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.contains(&"terminal restored");
assert!(
terminal_restored,
"cleanup must run after the terminal is restored"
);
cleanup_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.push("cleanup started");
app.cleanup_after_run()
},
);
let error = result.unwrap_err().to_string();
assert!(
error.contains(
"terminal input reader disconnected while waiting for startup prompt input fence"
),
"unexpected error: {error}"
);
assert!(app.pending_startup_prompt.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
let lifecycle = lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.clone();
let restored = lifecycle
.iter()
.position(|entry| *entry == "terminal restored")
.expect("fake terminal should be restored");
let cleanup = lifecycle
.iter()
.position(|entry| *entry == "cleanup started")
.expect("cleanup should start");
let critical = lifecycle
.iter()
.position(|entry| *entry == "critical cleanup joined")
.expect("critical startup cleanup should join");
assert!(restored < cleanup);
assert!(cleanup < critical);
}
#[test]
fn fatal_startup_failure_blocks_queued_prompt_and_restores_before_cleanup_joins() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.startup_readiness = StartupReadiness::Loading;
app.pending_startup_prompt = Some(PendingStartupPrompt {
prompt: "queued prompt".to_string(),
painted: true,
initial_candidate: false,
});
let lifecycle = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let run_lifecycle = std::sync::Arc::clone(&lifecycle);
let cleanup_lifecycle = std::sync::Arc::clone(&lifecycle);
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("queued prompt", "queued prompt".len()),
startup_prompt_queued: true,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
let mut drain_result = DrainResult::default();
drain_result
.startup_critical_loaded
.push((1, Err("injected critical startup failure".to_string())));
let mut autocomplete_candidates = Vec::new();
let result = crate::tui::run_with_cleanup(
&mut app,
|app| {
let _terminal_guard = crate::tui::terminal::test_guard_with_restore_probe(
std::sync::Arc::clone(&run_lifecycle),
)
.unwrap();
let critical_lifecycle = std::sync::Arc::clone(&run_lifecycle);
let critical_cancel = Arc::clone(&app.startup_cancel);
app.startup_critical_worker = Some(thread::spawn(move || {
while !critical_cancel.load(Ordering::SeqCst) {
thread::yield_now();
}
critical_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.push("critical cleanup joined");
}));
assert!(app.handle_startup_drain(
&mut ui_state,
&mut drain_result,
&mut autocomplete_candidates,
));
assert_eq!(app.startup_readiness, StartupReadiness::Failed);
assert!(app.pending_startup_prompt.is_none());
let (_input_sender, input_receiver) = bounded::<TerminalInputEvent>(1);
let mut pending_input_events = VecDeque::new();
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(app.worker.is_none());
assert!(!app.active_run);
let provider_cancel = Arc::new(AtomicBool::new(false));
let provider_lifecycle = std::sync::Arc::clone(&run_lifecycle);
let provider_cancel_for_thread = Arc::clone(&provider_cancel);
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while !provider_cancel_for_thread.load(Ordering::SeqCst) {
thread::yield_now();
}
provider_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.push("provider cleanup joined");
}),
cancel: provider_cancel,
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
shutdown_policy: WorkerShutdownPolicy::Cancel,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
Err(anyhow::anyhow!(
app.startup_failure
.clone()
.unwrap_or_else(|| "startup failure".to_string())
))
},
|app| {
let terminal_restored = cleanup_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.contains(&"terminal restored");
assert!(
terminal_restored,
"cleanup must run after the run closure drops the terminal guard"
);
cleanup_lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.push("cleanup started");
app.cleanup_after_run()
},
);
assert_eq!(
result.unwrap_err().to_string(),
"Mission Control startup failed: injected critical startup failure"
);
let lifecycle = lifecycle
.lock()
.expect("lifecycle lock should not be poisoned")
.clone();
let restored = lifecycle
.iter()
.position(|entry| *entry == "terminal restored")
.expect("fake terminal should be restored before cleanup");
let cleanup = lifecycle
.iter()
.position(|entry| *entry == "cleanup started")
.expect("cleanup closure should record its start");
let critical = lifecycle
.iter()
.position(|entry| *entry == "critical cleanup joined")
.expect("critical startup cleanup should join");
let provider = lifecycle
.iter()
.position(|entry| *entry == "provider cleanup joined")
.expect("provider cleanup should join");
assert!(restored < cleanup);
assert!(cleanup < critical);
assert!(cleanup < provider);
}
#[test]
fn cleanup_after_run_cancels_and_joins_startup_workers() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let critical_done = Arc::new(AtomicBool::new(false));
let critical_cancel = Arc::clone(&app.startup_cancel);
let critical_done_flag = Arc::clone(&critical_done);
app.startup_critical_worker = Some(thread::spawn(move || {
while !critical_cancel.load(Ordering::SeqCst) {
thread::yield_now();
}
critical_done_flag.store(true, Ordering::SeqCst);
}));
let decorative_done = Arc::new(AtomicBool::new(false));
let decorative_done_flag = Arc::clone(&decorative_done);
app.startup_decorative_worker = Some(thread::spawn(move || {
decorative_done_flag.store(true, Ordering::SeqCst);
}));
app.cleanup_after_run().unwrap();
assert!(app.startup_cancel.load(Ordering::SeqCst));
assert!(critical_done.load(Ordering::SeqCst));
assert!(decorative_done.load(Ordering::SeqCst));
assert!(app.startup_critical_worker.is_none());
assert!(app.startup_decorative_worker.is_none());
}
}