use super::*;
#[derive(Debug)]
struct PendingThemeSave {
request_id: u64,
theme_id: String,
revision: u64,
}
#[derive(Debug)]
struct ThemePickerRuntime {
opening_appearance: crate::appearance::RuntimeAppearance,
opening_revision: u64,
catalog: Option<crate::appearance::ThemeCatalog>,
preview_appearance: crate::appearance::RuntimeAppearance,
preview_revision: u64,
}
#[derive(Debug)]
struct ThemeWorker {
request_id: u64,
kind: ThemeWorkerKind,
handle: JoinHandle<ThemeWorkerResult>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThemeWorkerKind {
Catalog,
Save,
}
#[derive(Debug)]
struct ThemeWorkerResult {
delivery: Result<(), String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingModelSelection {
request_id: u64,
session_id: Option<String>,
generation: u64,
provider: String,
model: String,
}
#[derive(Debug)]
struct ModelSelectionWorkerOutcome {
result: Option<Result<TuiModelSelectionResult, String>>,
}
#[derive(Debug)]
struct ModelSelectionWorker {
request_id: u64,
cancel: Arc<AtomicBool>,
handle: JoinHandle<ModelSelectionWorkerOutcome>,
reconciled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RewindRequestKind {
Changes,
Plan,
Execute,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingRewind {
request_id: u64,
session_id: String,
generation: u64,
kind: RewindRequestKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RewindWorkerDelivery {
Delivered,
Canceled,
Failed,
}
#[derive(Debug)]
struct RewindWorker {
request_id: u64,
kind: RewindRequestKind,
cancel: Arc<AtomicBool>,
handle: JoinHandle<RewindWorkerOutcome>,
}
#[derive(Debug)]
struct RewindWorkerOutcome {
result: Option<Result<TuiRewindWorkerResult, String>>,
delivery: RewindWorkerDelivery,
}
#[derive(Debug)]
struct ExportWorker {
request_id: u64,
cancel: Arc<AtomicBool>,
handle: JoinHandle<ExportWorkerOutcome>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExportWorkerDelivery {
Delivered,
Canceled,
Failed,
}
#[derive(Debug)]
struct ExportWorkerOutcome {
result: Option<Result<TuiExportWorkerResult, String>>,
delivery: ExportWorkerDelivery,
}
#[derive(Debug, Default)]
struct DrainLoopBudgetState {
draw_before_next_drain: bool,
continue_after_pending_draw: bool,
input_paint_barrier: bool,
#[cfg(any(test, debug_assertions))]
input_paint_started: Option<Instant>,
}
impl DrainLoopBudgetState {
fn should_drain_at_loop_top(&self) -> bool {
!self.draw_before_next_drain
}
fn request_input_paint(
&mut self,
redraw_intent: &mut RedrawIntent,
perf_enabled: bool,
input_received_at: Instant,
) {
self.input_paint_barrier = true;
#[cfg(any(test, debug_assertions))]
if perf_enabled {
let earliest = self
.input_paint_started
.map_or(input_received_at, |started| started.min(input_received_at));
self.input_paint_started = Some(earliest);
}
#[cfg(not(any(test, debug_assertions)))]
let _ = (perf_enabled, input_received_at);
redraw_intent.request_immediate();
}
#[cfg(test)]
fn request_input_paint_at(&mut self, redraw_intent: &mut RedrawIntent, started: Instant) {
self.request_input_paint(redraw_intent, true, started);
}
fn input_paint_barrier_pending(&self) -> bool {
self.input_paint_barrier
}
#[cfg(any(test, debug_assertions))]
fn take_input_paint_started(&mut self) -> Option<Instant> {
self.input_paint_started.take()
}
fn apply_outcome(&mut self, outcome: &DrainOutcome) -> bool {
let changed = outcome.result.changed;
if outcome.exhausted && changed {
self.draw_before_next_drain = true;
self.continue_after_pending_draw = true;
}
changed
}
fn after_draw(&mut self) -> bool {
self.draw_before_next_drain = false;
self.input_paint_barrier = false;
#[cfg(any(test, debug_assertions))]
{
self.input_paint_started = None;
}
std::mem::take(&mut self.continue_after_pending_draw)
}
}
fn replace_prompt_burst_insert(display: &mut input::DisplayCommand, text: &str) -> bool {
match display {
input::DisplayCommand::Batch(commands) => commands
.iter_mut()
.any(|command| replace_prompt_burst_insert(command, text)),
input::DisplayCommand::Prompt(input::PromptCommand::Edit {
operation: input::PromptEditCommand::InsertChar(_),
viewport,
refresh_autocomplete,
}) => {
*display = input::DisplayCommand::Prompt(input::PromptCommand::InsertText {
text: text.to_string(),
viewport: *viewport,
refresh_autocomplete: *refresh_autocomplete,
});
true
}
_ => false,
}
}
fn classify_prompt_key_burst_event(
event: TerminalInputEvent,
state: &state::MissionControlState,
terminal_area: ratatui::layout::Rect,
autocomplete_candidates: &[state::AutocompleteCandidate],
) -> Option<input::InputCommand> {
match event {
TerminalInputEvent::KeyBurst(text) => {
if let Some(first) = text
.chars()
.next()
.filter(|ch| input::char_autofocuses_prompt(*ch))
{
let command = input::classify_key_with_all_viewports(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(first),
crossterm::event::KeyModifiers::NONE,
),
state,
viewports::key_viewports(terminal_area, state),
autocomplete_candidates,
);
let (action, display) = command.into_parts();
if let Some(mut display) = display
&& replace_prompt_burst_insert(&mut display, &text)
{
return Some(input::InputCommand::action_with_display(action, display));
}
}
if !input::input_accepts_paste(state) {
return None;
}
Some(input::classify_paste_with_all_viewports(
&text,
state,
viewports::paste_viewports(terminal_area, state),
autocomplete_candidates,
))
}
TerminalInputEvent::KeyBurstTooLarge => {
let command = input::classify_oversized_paste(state);
(!matches!(command, input::InputCommand::None)).then_some(command)
}
TerminalInputEvent::Event(_) => None,
}
}
#[cfg(test)]
#[inline]
fn apply_prompt_key_burst_event(
event: TerminalInputEvent,
state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
autocomplete_candidates: &[state::AutocompleteCandidate],
) -> bool {
let Some(command) =
classify_prompt_key_burst_event(event, state, terminal_area, autocomplete_candidates)
else {
return false;
};
let (action, display) = command.into_parts();
if let Some(display) = display {
display_reducer::apply_display_command(display, state, autocomplete_candidates);
}
if let input::InputAction::InsertConnectProviderText(text) = action {
state.insert_modal_field_text(&text.into_inner());
}
true
}
fn tui_perf_enabled(_state: &state::MissionControlState) -> bool {
#[cfg(any(test, debug_assertions))]
{
_state.perf.enabled()
}
#[cfg(not(any(test, debug_assertions)))]
{
false
}
}
fn draw_frame<B>(
terminal: &mut ratatui::Terminal<B>,
state: &mut state::MissionControlState,
last_area: &mut ratatui::layout::Rect,
_receiver: &Receiver<TuiEvent>,
redraw_intent: &mut RedrawIntent,
drain_budget_state: &mut DrainLoopBudgetState,
) -> Result<bool>
where
B: ratatui::backend::Backend,
B::Error: Send + Sync + 'static,
{
#[cfg(any(test, debug_assertions))]
let input_paint_started = drain_budget_state.take_input_paint_started();
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
};
let draw_result = terminal.draw(|frame| {
*last_area = frame.area();
render::draw(frame, state);
});
#[cfg(any(test, debug_assertions))]
if draw_result.is_ok() {
let completed_at = Instant::now();
if let Some(started) = draw_started {
state
.perf
.record_draw_us(completed_at.duration_since(started).as_micros() as u64);
}
if let Some(started) = input_paint_started {
state.perf.record_prompt_edit_to_draw_us(
completed_at.duration_since(started).as_micros() as u64,
);
}
}
draw_result?;
redraw_intent.clear();
let continue_after_pending_draw = drain_budget_state.after_draw();
let final_plain_refresh = state.finish_final_assistant_plain_first_paint();
if final_plain_refresh {
redraw_intent.request_immediate();
}
Ok(continue_after_pending_draw || final_plain_refresh)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupInputBoundary {
remaining: usize,
}
impl StartupInputBoundary {
fn capture(pending_input_events: usize, queued_input_events: usize) -> Self {
Self {
remaining: pending_input_events.saturating_add(queued_input_events),
}
}
fn is_complete(self) -> bool {
self.remaining == 0
}
fn consume(&mut self) {
self.remaining = self.remaining.saturating_sub(1);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PostDrainFlow {
Continue,
Exit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PostDrainProcessing {
flow: PostDrainFlow,
redraw_requested: bool,
skip_loop: bool,
}
struct PostDrainContext<'a> {
receiver: &'a Receiver<TuiEvent>,
input_events: &'a TerminalInputBridge,
terminal_area: ratatui::layout::Rect,
pending_input_events: &'a mut VecDeque<TerminalInputEvent>,
drain_budget_state: &'a mut DrainLoopBudgetState,
finish_completed_worker: bool,
}
impl MissionControlApp {
fn process_post_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_outcome: &mut DrainOutcome,
autocomplete_candidates: &mut Vec<state::AutocompleteCandidate>,
context: PostDrainContext<'_>,
) -> Result<PostDrainProcessing> {
let mut redraw_requested = false;
redraw_requested |= self.reap_session_maintenance(ui_state);
redraw_requested |= self.reconcile_system_prompt_worker(ui_state);
if self.reap_settings_persistence_worker(ui_state) {
refresh_skill_autocomplete_candidates(autocomplete_candidates, &self.skills);
redraw_requested = true;
}
if self.reap_rewind_workers(ui_state) {
redraw_requested = true;
}
if self.handle_rewind_drain(ui_state, &drain_outcome.result) {
redraw_requested = true;
}
if self.reap_export_workers(ui_state) {
redraw_requested = true;
}
if self.handle_export_drain(ui_state, &drain_outcome.result) {
redraw_requested = true;
}
if self.reap_theme_workers(ui_state) {
redraw_requested = true;
}
if self.reap_fast_mode_persistence_workers(ui_state) {
redraw_requested = true;
}
if self.handle_fast_mode_persistence_drain(ui_state, &drain_outcome.result) {
redraw_requested = true;
}
if self.reap_model_selection_workers(ui_state) {
redraw_requested = true;
}
if self.handle_model_selection_drain(ui_state, &drain_outcome.result) {
redraw_requested = true;
}
for refresh in std::mem::take(&mut drain_outcome.result.compaction_runtime_refresh) {
if self.apply_compaction_runtime_refresh(ui_state, refresh) {
redraw_requested = true;
}
}
if self.handle_model_catalog_drain(ui_state, &drain_outcome.result) {
redraw_requested = true;
}
for (request_id, result) in std::mem::take(&mut drain_outcome.result.theme_catalog_loaded) {
if self.handle_theme_catalog(ui_state, request_id, result) {
redraw_requested = true;
}
}
for (request_id, id, revision, result) in
std::mem::take(&mut drain_outcome.result.theme_persisted)
{
if self.handle_theme_persisted(ui_state, request_id, id, revision, result) {
redraw_requested = true;
}
}
for (provider_id, provider_result) in
std::mem::take(&mut drain_outcome.result.custom_provider_finished)
{
self.reconcile_worker_final_event_effect(
ui_state,
WorkerFinalEventEffect::CustomProviderFinished {
provider_id,
result: provider_result,
},
);
redraw_requested = true;
}
let critical_result_available = self.startup_readiness == StartupReadiness::Loading
&& (!drain_outcome.result.startup_critical_loaded.is_empty()
|| !self.deferred_startup_critical_loaded.is_empty());
if critical_result_available && self.startup_input_boundary.is_none() {
self.startup_input_boundary = Some(StartupInputBoundary::capture(
context.pending_input_events.len(),
context.input_events.receiver.len(),
));
}
let startup_input_boundary_open = self
.startup_input_boundary
.is_some_and(|boundary| !boundary.is_complete());
if startup_input_boundary_open {
self.deferred_startup_critical_loaded
.append(&mut drain_outcome.result.startup_critical_loaded);
} else if !self.deferred_startup_critical_loaded.is_empty() {
let deferred = std::mem::take(&mut self.deferred_startup_critical_loaded);
drain_outcome
.result
.startup_critical_loaded
.splice(0..0, deferred);
}
self.request_initial_prompt_flush(context.input_events)?;
match self.submit_initial_prompt_if_ready(
ui_state,
context.receiver,
context.terminal_area,
&context.input_events.receiver,
context.pending_input_events,
)? {
Some(actions::AppControlFlow::Exit) => {
return Ok(PostDrainProcessing {
flow: PostDrainFlow::Exit,
redraw_requested,
skip_loop: false,
});
}
Some(actions::AppControlFlow::Continue) => redraw_requested = true,
None => {}
}
if let Some(error) = self.startup_failure.clone() {
return Err(anyhow::anyhow!(error));
}
if self.handle_startup_drain(ui_state, &mut drain_outcome.result, autocomplete_candidates) {
redraw_requested = true;
}
if self.startup_readiness != StartupReadiness::Loading {
self.startup_input_boundary = None;
}
self.check_startup_worker_health(ui_state);
if let Some(error) = self.startup_failure.clone() {
return Err(anyhow::anyhow!(error));
}
if self.handle_background_session_drain(ui_state, &drain_outcome.result, context.receiver) {
redraw_requested = true;
}
redraw_requested |= self.poll_summarizer(ui_state);
self.request_initial_prompt_flush(context.input_events)?;
match self.submit_initial_prompt_if_ready(
ui_state,
context.receiver,
context.terminal_area,
&context.input_events.receiver,
context.pending_input_events,
)? {
Some(actions::AppControlFlow::Exit) => {
return Ok(PostDrainProcessing {
flow: PostDrainFlow::Exit,
redraw_requested,
skip_loop: false,
});
}
Some(actions::AppControlFlow::Continue) => redraw_requested = true,
None => {}
}
if let Some(error) = self.startup_failure.clone() {
return Err(anyhow::anyhow!(error));
}
if self.sync_steering_feedback(ui_state) {
redraw_requested = true;
}
let run_finished_worker_ids = drain_outcome.result.run_finished_worker_ids.clone();
let model_catalog_finished = drain_outcome.result.model_catalog_finished;
for worker_id in run_finished_worker_ids {
if self
.worker
.as_ref()
.is_some_and(|worker| worker.outcome.worker_id() == worker_id)
{
self.active_run = false;
if self.join_completed_worker(ui_state) {
redraw_requested = true;
}
}
}
if context.finish_completed_worker
&& self
.worker
.as_ref()
.is_some_and(|worker| worker.handle.is_finished())
&& self.finish_worker_if_ready(ui_state)
{
redraw_requested = true;
}
if context.drain_budget_state.apply_outcome(drain_outcome) {
redraw_requested = true;
}
if model_catalog_finished {
self.model_catalog_loading = false;
}
let skip_loop = drain_outcome.exhausted
&& !drain_outcome.result.changed
&& context.pending_input_events.is_empty();
Ok(PostDrainProcessing {
flow: PostDrainFlow::Continue,
redraw_requested,
skip_loop,
})
}
}
#[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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingExport {
request_id: u64,
session_id: String,
generation: u64,
}
enum PendingModelCatalogConsumer {
SetModelPicker,
ModelsModal,
}
impl MissionControlApp {
fn poll_summarizer(&mut self, ui_state: &mut state::MissionControlState) -> bool {
self.summarizer.refresh_config(&self.config, &self.settings);
self.summarizer
.set_session(self.state.current_session.clone());
let mut changed = false;
if ui_state.summary.session_id.as_deref() != self.state.active_session_id() {
ui_state.summary = Default::default();
ui_state.summary.session_id = self.state.active_session_id().map(str::to_string);
changed = true;
}
if let Some(enabled) = ui_state.summary.take_requested_enabled() {
if self.state.current_session.is_some() {
self.summarizer.set_enabled(enabled);
} else {
ui_state.summary.enabled = false;
ui_state.summary.error =
Some("Start a session before using summary controls.".into());
}
changed = true;
}
if let Some(snapshot) = self.summarizer.poll()
&& Some(snapshot.session_id.as_str()) == self.state.active_session_id()
{
ui_state.summary.apply(snapshot);
changed = true;
}
changed
}
}
pub(crate) struct MissionControlApp {
update_checks: crate::updates::UpdateChecks,
pub(crate) update_restart: Option<crate::updates::RestartContext>,
pub(crate) config: EffectiveConfig,
pub(crate) settings: crate::config::Settings,
summarizer: crate::summarizer::SummarizerRuntime,
pub(crate) appearance: crate::appearance::RuntimeAppearance,
pub(crate) theme_cli_override: bool,
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(in crate::tui) worker: Option<WorkerState>,
startup_cancel: Arc<AtomicBool>,
#[cfg(test)]
pub(crate) worker_start_gate: Option<Arc<std::sync::Barrier>>,
startup_critical_event_sent: Arc<AtomicBool>,
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>,
next_model_selection_request_id: u64,
pending_model_selection: Option<PendingModelSelection>,
model_selection_workers: Vec<ModelSelectionWorker>,
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_readiness: StartupReadiness,
pub(crate) startup_failure: Option<String>,
pub(crate) startup_warning: Option<String>,
startup_critical_worker: Option<JoinHandle<()>>,
startup_decorative_worker: Option<JoinHandle<()>>,
pub(crate) next_startup_request_id: u64,
pending_startup_prompt: Option<PendingStartupPrompt>,
pending_initial_prompt: Option<String>,
initial_prompt_rejected: bool,
initial_prompt_flush_ack: Option<Receiver<()>>,
startup_input_boundary: Option<StartupInputBoundary>,
deferred_startup_critical_loaded: Vec<(u64, Result<TuiStartupCritical, String>)>,
session_generation: u64,
next_session_preview_request_id: u64,
active_session_preview: Option<PendingSessionPreview>,
next_session_switch_request_id: u64,
queued_session_preview: Option<String>,
pending_session_switch: Option<PendingSessionSwitch>,
fast_mode_persistence: fast_mode_persistence::FastModePersistenceRuntime,
settings_persistence: settings_persistence::SettingsPersistenceRuntime,
session_maintenance: session_maintenance::SessionMaintenanceRuntime,
system_prompt: system_prompt::SystemPromptRuntime,
theme_catalog: Option<crate::appearance::ThemeCatalog>,
theme_catalog_request_id: u64,
theme_picker_runtime: Option<ThemePickerRuntime>,
theme_workers: Vec<ThemeWorker>,
pending_theme_save: Option<PendingThemeSave>,
next_theme_request_id: u64,
theme_revision: u64,
startup_prompt_launch_in_progress: bool,
next_rewind_request_id: u64,
pending_rewind: Option<PendingRewind>,
rewind_workers: Vec<RewindWorker>,
next_export_request_id: u64,
pending_export: Option<PendingExport>,
export_workers: Vec<ExportWorker>,
next_footer_git_branch_request_id: u64,
pending_footer_git_branch_request_id: Option<u64>,
footer_git_branch_worker: Option<completion_worker::CompletionWorker<TuiEvent>>,
pending_critical_events: VecDeque<TuiEvent>,
#[cfg(any(test, debug_assertions))]
tui_perf_enabled: bool,
}
mod actions;
mod auth;
mod catalog;
mod compaction;
mod completion_worker;
mod fast_mode_persistence;
mod session_maintenance;
mod settings_persistence;
mod system_prompt;
use fast_mode_persistence::FastModePersistenceRuntime;
mod display_reducer;
#[cfg(test)]
pub(super) fn apply_display_command_for_test(
command: input::DisplayCommand,
state: &mut state::MissionControlState,
autocomplete_candidates: &[state::AutocompleteCandidate],
) -> (bool, Option<String>) {
let result = display_reducer::apply_display_command(command, state, autocomplete_candidates);
(result.changed, result.copy)
}
mod export;
mod modals;
mod reducer;
mod rewind;
mod startup;
mod submit;
mod theme;
mod updates;
mod workers;
use startup::{PendingStartupPrompt, draw_initial_frame, initial_prompt_text_and_cursor};
impl MissionControlApp {
fn apply_ui_error(ui_state: &mut state::MissionControlState, error: impl std::fmt::Display) {
let message = error.to_string();
let _ = apply_worker_final_event(ui_state, WorkerFinalEvent::Error(message));
}
pub(crate) fn new(config: TuiSessionConfig, events: Sender<TuiEvent>) -> Self {
let config = config;
let appearance = config.appearance;
let theme_cli_override = config.theme_cli_override;
let (initial_prompt, initial_prompt_rejected) = match config.initial_prompt.as_deref() {
Some(prompt) => match state::normalize_prompt_text_bounded(prompt) {
Ok(prompt) => (Some(prompt), false),
Err(_) => (None, true),
},
None => (None, false),
};
let herdr_reporter = config.herdr_reporter;
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())
.with_herdr_reporter(herdr_reporter);
Self {
update_checks: crate::updates::UpdateChecks::default(),
update_restart: None,
summarizer: crate::summarizer::SummarizerRuntime::new(
config.config.clone(),
config.settings.clone(),
),
config: config.config,
settings: config.settings,
appearance,
theme_cli_override,
discovered_skills: config.discovered_skills,
instructions: config.instructions,
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,
#[cfg(test)]
worker_start_gate: 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,
next_model_selection_request_id: 0,
pending_model_selection: None,
model_selection_workers: Vec::new(),
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_readiness: StartupReadiness::Loading,
startup_failure: None,
startup_warning: None,
startup_cancel: Arc::new(AtomicBool::new(false)),
startup_critical_event_sent: Arc::new(AtomicBool::new(false)),
startup_critical_worker: None,
startup_decorative_worker: None,
next_startup_request_id: 0,
pending_startup_prompt: None,
pending_initial_prompt: initial_prompt,
initial_prompt_rejected,
initial_prompt_flush_ack: None,
deferred_startup_critical_loaded: Vec::new(),
startup_input_boundary: None,
session_generation: 0,
next_session_preview_request_id: 0,
active_session_preview: None,
queued_session_preview: None,
next_rewind_request_id: 0,
pending_rewind: None,
rewind_workers: Vec::new(),
next_session_switch_request_id: 0,
pending_session_switch: None,
fast_mode_persistence: FastModePersistenceRuntime::new(),
settings_persistence: settings_persistence::SettingsPersistenceRuntime::default(),
session_maintenance: session_maintenance::SessionMaintenanceRuntime::default(),
system_prompt: system_prompt::SystemPromptRuntime::default(),
theme_catalog: None,
theme_catalog_request_id: 0,
theme_picker_runtime: None,
theme_workers: Vec::new(),
pending_theme_save: None,
next_theme_request_id: 0,
theme_revision: 0,
startup_prompt_launch_in_progress: false,
next_export_request_id: 0,
pending_export: None,
export_workers: Vec::new(),
next_footer_git_branch_request_id: 0,
pending_footer_git_branch_request_id: None,
footer_git_branch_worker: None,
pending_critical_events: VecDeque::new(),
#[cfg(any(test, debug_assertions))]
tui_perf_enabled: crate::tui::perf::TuiPerfCounters::from_env().enabled(),
}
}
fn request_footer_git_branch_refresh(&mut self) {
if self.pending_footer_git_branch_request_id.is_some()
|| self.footer_git_branch_worker.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();
match completion_worker::CompletionWorker::spawn(
"magi-footer-branch",
self.events.clone(),
move || {
Ok(TuiEvent::FooterGitBranchLoaded {
request_id,
branch: crate::tui::sessions::commands::current_git_branch(&cwd),
})
},
) {
Ok(worker) => self.footer_git_branch_worker = Some(worker),
Err(_) => self.pending_footer_git_branch_request_id = None,
}
}
fn handle_footer_git_branch_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
let mut owned = DrainResult::default();
if let Some(worker) = self.footer_git_branch_worker.as_mut() {
if let Some(result) = worker.take_result() {
match result {
Ok(event) => apply_control_event_to_state(ui_state, event, &mut owned),
Err(_) => self.pending_footer_git_branch_request_id = None,
}
}
if worker.ready_to_reap() {
let _ = self.footer_git_branch_worker.take().unwrap().join();
}
}
for (request_id, branch) in owned
.footer_git_branch_loaded
.iter()
.chain(&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);
let (switch_changed, selected_title) =
self.handle_session_switch_drain_with_title(ui_state, drain_result, receiver);
changed |= switch_changed;
changed |= self.handle_session_title_drain_with_skip(
ui_state,
drain_result,
selected_title.as_ref(),
);
changed |= self.handle_footer_git_branch_drain(ui_state, drain_result);
changed
}
#[cfg(test)]
fn handle_session_switch_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
receiver: &Receiver<TuiEvent>,
) -> bool {
self.handle_session_switch_drain_with_title(ui_state, drain_result, receiver)
.0
}
fn handle_session_switch_drain_with_title(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
receiver: &Receiver<TuiEvent>,
) -> (bool, Option<(String, Option<String>)>) {
let mut changed = false;
let mut selected_title = None;
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;
if self.pending_export.is_some() {
ui_state.status =
"session export is in progress; session switch canceled".to_string();
changed = true;
continue;
}
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;
}
preserve_critical_tui_events(receiver, &mut self.pending_critical_events);
self.state.current_session = Some(loaded.session.clone());
self.state.clear_fast_observations();
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();
if let Some(reporter) = self.state.herdr_reporter.as_ref() {
reporter.report_agent_session_with_source(
loaded.session.id(),
crate::herdr::HerdrSessionStartSource::Select,
);
reporter.report_title(loaded.latest_title.as_deref());
}
selected_title =
Some((loaded.session.id().to_string(), loaded.latest_title.clone()));
changed = true;
}
Err(error) => {
ui_state.status = error.clone();
changed = true;
}
}
}
(changed, selected_title)
}
#[cfg(test)]
fn handle_session_title_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
self.handle_session_title_drain_with_skip(ui_state, drain_result, None)
}
fn handle_session_title_drain_with_skip(
&mut self,
_ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
selected_title: Option<&(String, Option<String>)>,
) -> bool {
let Some(reporter) = self.state.herdr_reporter.as_ref() else {
return false;
};
let active_session_id = self.state.active_session_id();
for (session_id, title) in &drain_result.session_title_updated {
if selected_title.is_some_and(|(selected_id, selected)| {
selected_id == session_id && selected.as_deref() == Some(title.as_str())
}) {
continue;
}
if active_session_id == Some(session_id.as_str()) {
reporter.report_title(Some(title));
}
}
false
}
fn input_reader_disconnected_error(&self) -> anyhow::Error {
if self.pending_startup_prompt.is_some() {
anyhow::anyhow!(
"terminal input reader disconnected while waiting for startup prompt input fence"
)
} else {
anyhow::anyhow!("terminal input reader disconnected unexpectedly")
}
}
fn consume_startup_input_boundary_events(&mut self, count: usize) {
if let Some(boundary) = self.startup_input_boundary.as_mut() {
boundary.remaining = boundary.remaining.saturating_sub(count);
}
}
fn consume_startup_input_boundary_event(&mut self) {
if let Some(boundary) = self.startup_input_boundary.as_mut() {
boundary.consume();
}
}
pub(crate) fn run(&mut self, receiver: Receiver<TuiEvent>) -> Result<()> {
let input_events = TerminalInputBridge::spawn();
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 = vec![self.config.thinking_level];
let (initial_prompt, initial_prompt_cursor) =
initial_prompt_text_and_cursor(self.pending_initial_prompt.as_deref());
let mut state = state::MissionControlState {
provider,
model,
provider_ready: self.state.auth_state.is_ready(),
color_enabled: self.appearance.policy.color_enabled,
reduced_motion: self.appearance.reduced_motion,
theme: crate::tui::theme::MissionControlTheme::from_runtime_appearance(
&self.appearance,
0,
),
fast_mode_enabled: self.settings.fast.enabled,
auto_compaction: self.settings.compaction.auto.clone(),
fast_mode_capable: false,
fast_mode_effective: false,
thinking_level: self.config.thinking_level,
thinking_levels: thinking_levels.clone(),
prompt_editor: state::PromptEditor::from_canonical(
&initial_prompt,
initial_prompt_cursor,
),
focus_pane: state::TuiFocusPane::Prompt,
startup_prompt_queued: self.pending_initial_prompt.is_some(),
status: self.startup_initial_status(),
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);
state.update_context_window(crate::context::ContextBudget::default().max_tokens);
let mut autocomplete_candidates = tui_autocomplete_candidates(&self.commands);
let mut last_area = draw_initial_frame(&mut terminal, &mut state)?;
self.initialize_startup_prompt_queue(true);
let startup_request_id = self.start_startup_loader();
state.pending_startup_critical_request_id = Some(startup_request_id);
state.pending_startup_decorative_request_id = Some(startup_request_id);
let mut redraw_intent = RedrawIntent::None;
let mut pending_input_events: VecDeque<TerminalInputEvent> = VecDeque::new();
let mut last_prompt_cursor_blink = Instant::now();
let mut last_footer_git_branch_refresh = Instant::now();
let mut last_fast_mode_rainbow =
if state.fast_mode_rainbow_active() || state.welcome_animation_active() {
Some(Instant::now())
} else {
None
};
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut drain_budget_state = DrainLoopBudgetState::default();
let mut input_priority_turn = false;
loop {
if let Some(version) = self.update_checks.poll(Instant::now()) {
state.apply_output_event(&OutputEvent::Diagnostic {
level: "info".into(),
message: format!("magi-code {version} is available. Run /update to install with Cargo and reopen this idle session."),
});
redraw_intent.request_immediate();
}
self.sync_startup_prompt_signal(&mut state);
if drain_budget_state.input_paint_barrier_pending() {
draw_frame(
&mut terminal,
&mut state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut drain_budget_state,
)?;
self.mark_pending_startup_prompt_painted();
continue;
}
let input_queued_for_priority =
!pending_input_events.is_empty() || !input_events.receiver.is_empty();
let skip_tui_drain_for_input = input_queued_for_priority && input_priority_turn;
let mut drain_outcome =
if drain_budget_state.should_drain_at_loop_top() && !skip_tui_drain_for_input {
#[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_with_pending(
&receiver,
&mut self.pending_critical_events,
&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_with_pending(
&receiver,
&mut self.pending_critical_events,
&mut state,
UI_DRAIN_BUDGET,
)
}
}
#[cfg(not(any(test, debug_assertions)))]
{
drain_tui_events_with_pending(
&receiver,
&mut self.pending_critical_events,
&mut state,
UI_DRAIN_BUDGET,
)
}
} else {
DrainOutcome::default()
};
if !skip_tui_drain_for_input {
input_priority_turn = true;
}
let post_drain = self.process_post_drain(
&mut state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: last_area,
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)?;
if post_drain.redraw_requested {
redraw_intent.request_immediate();
}
if post_drain.flow == PostDrainFlow::Exit {
break;
}
let now = Instant::now();
let rainbow_advanced =
tick_fast_mode_rainbow(&mut state, &mut last_fast_mode_rainbow, now);
let activity_advanced = state.tick_activity_motion(now);
if rainbow_advanced || activity_advanced {
redraw_intent.request_immediate();
}
if post_drain.skip_loop && !rainbow_advanced && !activity_advanced {
continue;
}
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) {
let continue_after_pending_draw = draw_frame(
&mut terminal,
&mut state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut drain_budget_state,
)?;
self.mark_pending_startup_prompt_painted();
if continue_after_pending_draw {
continue;
}
}
let (input_event, input_received_at) = if let Some(event) =
pending_input_events.pop_front()
{
self.consume_startup_input_boundary_event();
(event, Instant::now())
} else if let Ok(event) = input_events.receiver.try_recv() {
self.consume_startup_input_boundary_event();
(event, Instant::now())
} else {
if !self.pending_critical_events.is_empty() {
continue;
}
let now = Instant::now();
let timeout = next_ui_timer_timeout(
&state,
last_prompt_cursor_blink,
last_footer_git_branch_refresh,
last_fast_mode_rainbow,
now,
);
let timeout = if self.settings_persistence.is_pending()
|| self.session_maintenance.is_pending()
|| self.system_prompt.is_pending()
|| self.footer_git_branch_worker.is_some()
{
Some(
timeout
.unwrap_or(Duration::MAX)
.min(Duration::from_millis(20)),
)
} else {
timeout
};
let timeout = if self.state.current_session.is_some() {
Some(
timeout
.unwrap_or(Duration::MAX)
.min(Duration::from_millis(100)),
)
} else {
timeout
};
crossbeam_channel::select_biased! {
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());
}
let post_drain = self.process_post_drain(
&mut state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: last_area,
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: false,
},
)?;
if post_drain.redraw_requested {
redraw_intent.request_immediate();
}
if post_drain.flow == PostDrainFlow::Exit {
break;
}
continue;
}
Err(_) => break,
}
}
recv(input_events.receiver) -> event => {
match event {
Ok(event) => {
self.consume_startup_input_boundary_event();
(event, Instant::now())
}
Err(_) => return Err(self.input_reader_disconnected_error()),
}
}
default(timeout.unwrap_or(Duration::MAX)) => {
continue;
}
}
};
input_priority_turn = false;
let input_event = match input_event {
TerminalInputEvent::KeyBurst(text) => {
let prompt_revision_before = state.prompt_content_revision();
let Some(command) = classify_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(text),
&state,
last_area,
&autocomplete_candidates,
) else {
continue;
};
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
let (flow, _) = self.reduce_input_command(
command,
&mut state,
&mut action_context,
prompt_revision_before,
);
if matches!(flow, actions::AppControlFlow::Exit) {
break;
}
let input_changed = state.prompt_content_revision() != prompt_revision_before;
redraw_intent.request_immediate();
if input_changed {
drain_budget_state.request_input_paint(
&mut redraw_intent,
tui_perf_enabled(&state),
input_received_at,
);
}
continue;
}
TerminalInputEvent::KeyBurstTooLarge => {
let prompt_revision_before = state.prompt_content_revision();
let Some(command) = classify_prompt_key_burst_event(
TerminalInputEvent::KeyBurstTooLarge,
&state,
last_area,
&autocomplete_candidates,
) else {
continue;
};
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
let (flow, _) = self.reduce_input_command(
command,
&mut state,
&mut action_context,
prompt_revision_before,
);
if matches!(flow, actions::AppControlFlow::Exit) {
break;
}
redraw_intent.request_immediate();
continue;
}
TerminalInputEvent::Event(event) => event,
};
match input_event {
crossterm::event::Event::Key(key) => {
let prompt_revision_before = state.prompt_content_revision();
let command = input::classify_key_with_all_viewports(
key,
&state,
viewports::key_viewports(last_area, &state),
&autocomplete_candidates,
);
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
let (flow, _) = self.reduce_input_command(
command,
&mut state,
&mut action_context,
prompt_revision_before,
);
if matches!(flow, actions::AppControlFlow::Exit) {
break;
}
if state.session_picker_visible() {
self.refresh_selected_session_preview(&mut state);
}
let input_changed = state.prompt_content_revision() != prompt_revision_before;
redraw_intent.request_immediate();
if input_changed {
drain_budget_state.request_input_paint(
&mut redraw_intent,
tui_perf_enabled(&state),
input_received_at,
);
}
}
crossterm::event::Event::Paste(text) => {
let prompt_revision_before = state.prompt_content_revision();
let command = input::classify_paste_with_all_viewports(
&text,
&state,
viewports::paste_viewports(last_area, &state),
&autocomplete_candidates,
);
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
let (flow, _) = self.reduce_input_command(
command,
&mut state,
&mut action_context,
prompt_revision_before,
);
if matches!(flow, actions::AppControlFlow::Exit) {
break;
}
let input_changed = state.prompt_content_revision() != prompt_revision_before;
redraw_intent.request_immediate();
if input_changed {
drain_budget_state.request_input_paint(
&mut redraw_intent,
tui_perf_enabled(&state),
input_received_at,
);
}
}
crossterm::event::Event::Mouse(mouse) => {
let (mouse, scroll_rows, coalesced_events) = coalesce_mouse_scroll_event(
mouse,
last_area,
&state,
&input_events.receiver,
&mut pending_input_events,
);
self.consume_startup_input_boundary_events(coalesced_events);
let scroll_only = mouse_scroll_batch(mouse, last_area, &state).is_some();
let command =
input::mouse::classify_mouse_event(mouse, last_area, &state, scroll_rows)
.map(input::InputCommand::display)
.unwrap_or(input::InputCommand::None);
let prompt_revision_before = state.prompt_content_revision();
#[cfg(any(test, debug_assertions))]
let scroll_measurement = (scroll_only && state.perf.enabled()).then(|| {
(
Instant::now(),
input_events
.receiver
.len()
.saturating_add(pending_input_events.len()),
)
});
let mut action_context = actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut autocomplete_candidates,
clipboard: &mut clipboard,
last_area,
};
let (flow, result) = self.reduce_input_command(
command,
&mut state,
&mut action_context,
prompt_revision_before,
);
if matches!(flow, actions::AppControlFlow::Exit) {
break;
}
if result.changed {
#[cfg(any(test, debug_assertions))]
if let Some((started, backlog)) = scroll_measurement {
state.perf.record_scroll_applied(started, backlog);
}
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();
}
}
crossterm::event::Event::Resize(width, height) => {
last_area = ratatui::layout::Rect::new(0, 0, width, height);
redraw_intent.request_immediate();
}
_ => {}
}
}
self.startup_cancel.store(true, Ordering::SeqCst);
let _ = self.request_cancel_active_worker(&mut state);
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
}
fn current_fast_request_state(
&self,
ui_state: &state::MissionControlState,
) -> crate::fast::FastRequestState {
crate::fast::resolve_fast_capability(
&self.settings,
&ui_state.provider,
&ui_state.model,
&self.config.paths,
self.config.custom_providers.get(&ui_state.provider),
crate::fast::FastWorkload::Primary,
)
}
pub(crate) fn fast_mode_status(&self, ui_state: &state::MissionControlState) -> String {
let capability = self.current_fast_request_state(ui_state);
let display_state = ui_state.fast_display_state(self.settings.fast.enabled, &capability);
let observation = ui_state
.selected_fast_observation()
.map(state::StoredFastObservation::to_fast_observation);
match display_state {
state::FastDisplayState::Off => crate::commands::runtime::fast_mode_status(
false,
&ui_state.provider,
&ui_state.model,
false,
),
state::FastDisplayState::Unavailable => crate::commands::runtime::fast_mode_status(
true,
&ui_state.provider,
&ui_state.model,
false,
),
state::FastDisplayState::WillRequest => crate::commands::runtime::fast_mode_status(
true,
&ui_state.provider,
&ui_state.model,
true,
),
state::FastDisplayState::Confirmed
| state::FastDisplayState::Different(_)
| state::FastDisplayState::Unconfirmed => crate::fast::fast_status_message(
self.settings.fast.enabled,
&ui_state.provider,
&ui_state.model,
&capability,
observation.as_ref(),
),
}
}
pub(crate) fn refresh_fast_mode_state(&self, ui_state: &mut state::MissionControlState) {
let capability = self.current_fast_request_state(ui_state);
ui_state.refresh_fast_mode_state(self.settings.fast.enabled, &capability);
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
struct TestClipboard {
succeed: bool,
copied: Vec<String>,
}
impl input::clipboard::ClipboardWriter for TestClipboard {
fn copy(&mut self, text: &str) -> anyhow::Result<()> {
self.copied.push(text.to_string());
if self.succeed {
Ok(())
} else {
Err(anyhow::anyhow!("clipboard unavailable"))
}
}
}
#[test]
fn selected_prompt_copy_action_uses_clipboard_success_and_failure_toasts() {
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();
let mut autocomplete_candidates = Vec::new();
let mut success = TestClipboard {
succeed: true,
copied: Vec::new(),
};
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut success);
assert!(matches!(
app.apply_input_action(
input::InputAction::CopySelectedPrompt("selected".to_string()),
&mut ui_state,
&mut context,
),
actions::AppControlFlow::Continue
));
assert_eq!(success.copied, vec!["selected"]);
assert_eq!(
ui_state.toast.as_ref().map(|toast| toast.message.as_str()),
Some("Copied to clipboard")
);
let mut failure = TestClipboard {
succeed: false,
copied: Vec::new(),
};
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut failure);
assert!(matches!(
app.apply_input_action(
input::InputAction::CopySelectedPrompt("selected".to_string()),
&mut ui_state,
&mut context,
),
actions::AppControlFlow::Continue
));
assert_eq!(failure.copied, vec!["selected"]);
assert_eq!(
ui_state.toast.as_ref().map(|toast| toast.message.as_str()),
Some("Clipboard copy failed")
);
}
#[test]
fn oversized_prompt_burst_preserves_editor_state_and_modal_ownership() {
let mut state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("draft", 3),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
state.insert_prompt_char('!', 1, 80);
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Changed);
state.prompt_editor.start_selection_at(1);
state.prompt_editor.update_selection_at(3);
let before_text = state.prompt_plain_text();
let before_cursor = state.prompt_cursor();
let before_selection = state.prompt_selection_text();
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurstTooLarge,
&mut state,
test_area(),
&[],
));
assert_eq!(state.prompt_plain_text(), before_text);
assert_eq!(state.prompt_cursor(), before_cursor);
assert_eq!(state.prompt_selection_text(), before_selection);
assert_eq!(state.status, state::PROMPT_TOO_LARGE_STATUS);
assert_eq!(state.redo_prompt_edit(), state::PromptEditResult::Changed);
let mut modal_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("draft", 5),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
modal_state.open_rewind_modal("rewind".to_string(), None, false);
let modal_status = modal_state.status.clone();
assert!(!apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurstTooLarge,
&mut modal_state,
test_area(),
&[],
));
assert_eq!(modal_state.prompt_plain_text(), "draft");
assert_eq!(modal_state.status, modal_status);
assert!(modal_state.rewind_modal_visible());
}
#[test]
fn accepted_prompt_burst_is_one_undo_step() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let text = "aé\t\n界".to_string();
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(text.clone()),
&mut state,
test_area(),
&[],
));
assert_eq!(state.prompt_plain_text(), text);
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Changed);
assert!(state.prompt_plain_text().is_empty());
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Unchanged);
}
#[test]
fn prompt_key_burst_autofocus_preserves_full_text_and_undo_grouping() {
for focus_pane in [
state::TuiFocusPane::Transcript,
state::TuiFocusPane::ActivityTree,
state::TuiFocusPane::ActivityDetail,
state::TuiFocusPane::Summary,
] {
for text in ["Hello, world!", "7 + 2", "界é\t\nrest", "/n"] {
let mut state = state::MissionControlState {
focus_pane,
show_help: true,
..Default::default()
};
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(text.to_string()),
&mut state,
test_area(),
&[],
));
assert_eq!(state.focus_pane, state::TuiFocusPane::Prompt);
assert!(!state.show_help);
assert_eq!(state.prompt_plain_text(), text);
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Changed);
assert!(state.prompt_plain_text().is_empty());
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Unchanged);
}
}
}
#[test]
fn prompt_key_burst_autofocus_preserves_modal_and_paste_ownership() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Transcript,
..Default::default()
};
for text in ["!hello", " hello", ""] {
assert!(!apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(text.to_string()),
&mut state,
test_area(),
&[],
));
}
assert_eq!(
input::classify_paste_with_all_viewports(
"/n",
&state,
viewports::paste_viewports(test_area(), &state),
&[],
),
input::InputCommand::None,
);
state.open_rewind_modal("rewind".to_string(), None, false);
assert!(!apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst("/n".to_string()),
&mut state,
test_area(),
&[],
));
assert_eq!(state.focus_pane, state::TuiFocusPane::Transcript);
assert!(state.prompt_plain_text().is_empty());
assert!(state.rewind_modal_visible());
}
#[test]
fn prompt_key_burst_uses_the_full_prospective_prompt_for_paste_collapse() {
let payload = "one\ntwo\nthree";
for prefix in ['/', '!'] {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
state.insert_prompt_char(prefix, 1, 80);
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(payload.to_string()),
&mut state,
test_area(),
&[],
));
assert_eq!(
state.prompt_editor.visible_text(),
format!("{prefix}{payload}")
);
}
}
#[test]
fn accepted_modal_burst_uses_single_line_normalization() {
let mut state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("draft", 0),
..Default::default()
};
state.open_connect_provider(Vec::new(), None);
state.start_connect_custom_configuration();
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst("Local\r\nProvider".to_string()),
&mut state,
test_area(),
&[],
));
assert_eq!(
state
.modals
.connect_provider
.as_ref()
.unwrap()
.label
.value(),
"Local Provider"
);
assert_eq!(state.prompt_plain_text(), "draft");
}
#[test]
fn accepted_large_prompt_burst_is_one_undo_step() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let text = "x".repeat(state::MAX_PROMPT_BYTES);
assert!(apply_prompt_key_burst_event(
TerminalInputEvent::KeyBurst(text.clone()),
&mut state,
test_area(),
&[],
));
assert_eq!(state.prompt_plain_text().len(), state::MAX_PROMPT_BYTES);
assert_eq!(state.undo_prompt_edit(), state::PromptEditResult::Changed);
assert!(state.prompt_plain_text().is_empty());
}
#[test]
fn accepted_prompt_burst_refreshes_autocomplete_through_controller_reducer() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let candidates = vec![state::AutocompleteCandidate::slash_command(
"new",
"start a new session",
)];
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let command = classify_prompt_key_burst_event(
TerminalInputEvent::KeyBurst("/n".to_string()),
&state,
test_area(),
&candidates,
)
.expect("prompt burst command");
let prompt_revision_before = state.prompt_content_revision();
let mut autocomplete_candidates = candidates;
let mut clipboard = TestClipboard {
succeed: true,
copied: Vec::new(),
};
let mut context = action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
let (flow, result) =
app.reduce_input_command(command, &mut state, &mut context, prompt_revision_before);
assert!(matches!(flow, actions::AppControlFlow::Continue));
assert!(result.changed);
assert_eq!(state.prompt_plain_text(), "/n");
assert!(state.autocomplete_visible());
}
pub(super) fn test_paths(root: &std::path::Path) -> crate::config::McPaths {
let mut paths = crate::config::McPaths::from_root_and_project_dir(
root.join("mc"),
root.join("project"),
);
paths.user_agents = root.join("mc/agents");
paths
}
pub(super) fn test_app(
temp: &tempfile::TempDir,
sender: Sender<TuiEvent>,
) -> MissionControlApp {
test_app_with_initial_prompt(temp, sender, None)
}
pub(super) fn test_app_with_initial_prompt(
temp: &tempfile::TempDir,
sender: Sender<TuiEvent>,
initial_prompt: Option<String>,
) -> 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,
auth: None,
paths: test_paths(temp.path()),
},
settings: crate::config::Settings::default(),
appearance: crate::appearance::RuntimeAppearance::default(),
theme_cli_override: false,
initial_prompt,
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(),
herdr_reporter: None,
},
sender,
);
app.startup_readiness = StartupReadiness::Ready;
app
}
pub(super) fn action_context<'a, C: input::clipboard::ClipboardWriter + 'a>(
receiver: &'a Receiver<TuiEvent>,
autocomplete_candidates: &'a mut Vec<state::AutocompleteCandidate>,
clipboard: &'a mut C,
) -> actions::ActionContext<'a> {
actions::ActionContext {
receiver,
autocomplete_candidates,
clipboard,
last_area: ratatui::layout::Rect::new(0, 0, 100, 30),
}
}
pub(super) fn test_area() -> ratatui::layout::Rect {
ratatui::layout::Rect::new(0, 0, 100, 30)
}
fn test_startup_critical() -> 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 process_test_post_drain(
app: &mut MissionControlApp,
ui_state: &mut state::MissionControlState,
receiver: &Receiver<TuiEvent>,
input_events: &TerminalInputBridge,
drain_budget_state: &mut DrainLoopBudgetState,
drain_outcome: &mut DrainOutcome,
) {
let mut autocomplete_candidates = Vec::new();
let mut pending_input_events = VecDeque::new();
app.process_post_drain(
ui_state,
drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver,
input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
}
#[test]
fn startup_readiness_defers_simultaneous_enter_until_queue_paint_and_fence() {
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_initial_prompt = Some("initial".to_string());
app.initialize_startup_prompt_queue(true);
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("initial", "initial".len()),
focus_pane: state::TuiFocusPane::Prompt,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
let mut drain_outcome = DrainOutcome::default();
drain_outcome.result.startup_critical_loaded.push((
1,
Ok(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,
}),
));
let mut autocomplete_candidates = Vec::new();
let mut pending_input_events = VecDeque::from([TerminalInputEvent::Event(
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)),
)]);
let (_test_input_sender, test_input_receiver) = bounded::<TerminalInputEvent>(1);
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
app.process_post_drain(
&mut ui_state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert_eq!(app.deferred_startup_critical_loaded.len(), 1);
assert_eq!(pending_input_events.len(), 1);
assert!(
app.pending_startup_prompt
.as_ref()
.is_some_and(|pending| pending.initial_candidate)
);
let TerminalInputEvent::Event(crossterm::event::Event::Key(key)) =
pending_input_events.pop_front().unwrap()
else {
panic!("expected simultaneous Enter event");
};
app.consume_startup_input_boundary_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,
);
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut action_context =
action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
assert!(matches!(
app.apply_input_action(action, &mut ui_state, &mut action_context),
actions::AppControlFlow::Continue
));
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert!(ui_state.prompt_plain_text().is_empty());
assert!(app.pending_startup_prompt.as_ref().is_some_and(|pending| {
pending.prompt == "initial" && !pending.initial_candidate && !pending.painted
}));
assert!(app.worker.is_none());
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut last_area = test_area();
let mut redraw_intent = RedrawIntent::None;
draw_frame(
&mut terminal,
&mut ui_state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut drain_budget_state,
)
.unwrap();
app.mark_pending_startup_prompt_painted();
let (fence_sender, fence_receiver) = bounded(1);
app.initial_prompt_flush_ack = Some(fence_receiver);
let mut readiness_outcome = DrainOutcome::default();
app.process_post_drain(
&mut ui_state,
&mut readiness_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: last_area,
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert!(app.deferred_startup_critical_loaded.is_empty());
assert!(app.pending_startup_prompt.is_some());
assert!(app.initial_prompt_flush_ack.is_some());
fence_sender.send(()).unwrap();
let deadline = Instant::now() + Duration::from_secs(1);
let mut launches = 0;
while launches == 0 {
match app
.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
last_area,
&test_input_receiver,
&mut pending_input_events,
)
.unwrap()
{
Some(actions::AppControlFlow::Continue) => launches += 1,
Some(actions::AppControlFlow::Exit) => panic!("queued prompt exited the app"),
None => {
assert!(
Instant::now() < deadline,
"startup fence did not acknowledge"
);
thread::yield_now();
}
}
}
assert_eq!(launches, 1);
assert!(app.pending_startup_prompt.is_none());
assert!(app.active_run);
assert!(app.worker.is_some());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
last_area,
&test_input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
app.cleanup_after_run().unwrap();
}
#[test]
fn startup_readiness_boundary_is_finite_when_input_is_replenished() {
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 {
focus_pane: state::TuiFocusPane::Prompt,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
assert!(!app.submit(
"queued prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
));
app.mark_pending_startup_prompt_painted();
ui_state.set_prompt_text("draft", "draft".len());
let enter_event = || {
TerminalInputEvent::Event(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
),
))
};
let mut pending_input_events = VecDeque::from([enter_event()]);
let (input_sender, input_receiver) = bounded::<TerminalInputEvent>(8);
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
let mut drain_outcome = DrainOutcome::default();
drain_outcome.result.startup_critical_loaded.push((
1,
Ok(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,
}),
));
let mut autocomplete_candidates = Vec::new();
app.process_post_drain(
&mut ui_state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert_eq!(
app.startup_input_boundary,
Some(StartupInputBoundary { remaining: 1 })
);
assert_eq!(app.deferred_startup_critical_loaded.len(), 1);
let TerminalInputEvent::Event(crossterm::event::Event::Key(key)) =
pending_input_events.pop_front().unwrap()
else {
panic!("expected boundary Enter event");
};
app.consume_startup_input_boundary_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,
);
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut action_ctx =
action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
assert!(matches!(
app.apply_input_action(action, &mut ui_state, &mut action_ctx),
actions::AppControlFlow::Continue
));
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("queued prompt")
);
assert_eq!(ui_state.prompt_plain_text(), "draft");
assert!(!app.active_run);
assert!(app.worker.is_none());
for _ in 0..8 {
pending_input_events.push_back(enter_event());
}
let (readiness_fence_sender, readiness_fence_receiver) = bounded(1);
app.initial_prompt_flush_ack = Some(readiness_fence_receiver);
let mut readiness_outcome = DrainOutcome::default();
app.process_post_drain(
&mut ui_state,
&mut readiness_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert!(app.startup_input_boundary.is_none());
assert!(app.deferred_startup_critical_loaded.is_empty());
assert_eq!(pending_input_events.len(), 8);
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("queued prompt")
);
assert!(!app.active_run);
readiness_fence_sender.send(()).unwrap();
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
for _ in 0..8 {
let TerminalInputEvent::Event(crossterm::event::Event::Key(key)) =
pending_input_events.pop_front().unwrap()
else {
panic!("expected replenished Enter event");
};
app.consume_startup_input_boundary_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,
);
let mut clipboard = input::clipboard::Osc52Clipboard;
let mut action_ctx =
action_context(&receiver, &mut autocomplete_candidates, &mut clipboard);
assert!(matches!(
app.apply_input_action(action, &mut ui_state, &mut action_ctx),
actions::AppControlFlow::Continue
));
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("queued prompt")
);
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()
);
let (fence_sender, fence_receiver) = bounded(1);
fence_sender.send(()).unwrap();
app.initial_prompt_flush_ack = Some(fence_receiver);
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.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();
drop(input_sender);
}
#[test]
fn startup_readiness_routes_one_key_burst_without_replacing_queued_prompt() {
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 {
focus_pane: state::TuiFocusPane::Prompt,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
assert!(!app.submit(
"queued prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
));
ui_state.set_prompt_text("draft", "draft".len());
let mut pending_input_events =
VecDeque::from([TerminalInputEvent::KeyBurst(" +burst".to_string())]);
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
let mut drain_outcome = DrainOutcome::default();
drain_outcome
.result
.startup_critical_loaded
.push((1, Ok(test_startup_critical())));
let mut autocomplete_candidates = Vec::new();
app.process_post_drain(
&mut ui_state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert_eq!(
app.startup_input_boundary,
Some(StartupInputBoundary { remaining: 1 })
);
assert_eq!(app.deferred_startup_critical_loaded.len(), 1);
assert!(app.pending_startup_prompt.as_ref().is_some_and(|pending| {
pending.prompt == "queued prompt" && !pending.painted && !pending.initial_candidate
}));
let event = pending_input_events.pop_front().unwrap();
app.consume_startup_input_boundary_event();
let prompt_revision_before = ui_state.prompt_content_revision();
assert!(apply_prompt_key_burst_event(
event,
&mut ui_state,
test_area(),
&autocomplete_candidates,
));
app.mark_initial_prompt_input(
ui_state.prompt_content_revision() != prompt_revision_before,
None,
&mut ui_state,
);
assert_eq!(ui_state.prompt_plain_text(), "draft +burst");
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("queued prompt")
);
assert_eq!(
app.startup_input_boundary,
Some(StartupInputBoundary { remaining: 0 })
);
assert_eq!(app.deferred_startup_critical_loaded.len(), 1);
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert!(!app.active_run);
assert!(app.worker.is_none());
let mut readiness_outcome = DrainOutcome::default();
app.process_post_drain(
&mut ui_state,
&mut readiness_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert!(app.initial_prompt_flush_ack.is_none());
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_events.receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut last_area = test_area();
let mut redraw_intent = RedrawIntent::None;
draw_frame(
&mut terminal,
&mut ui_state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut drain_budget_state,
)
.unwrap();
app.mark_pending_startup_prompt_painted();
let (_fence_sender, fence_receiver) = bounded(1);
app.initial_prompt_flush_ack = Some(fence_receiver);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
last_area,
&input_events.receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn startup_readiness_routes_oversized_burst_without_prompt_mutation_or_fence_bypass() {
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 {
focus_pane: state::TuiFocusPane::Prompt,
pending_startup_critical_request_id: Some(1),
..Default::default()
};
assert!(!app.submit(
"queued prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
));
ui_state.set_prompt_text("draft", "draft".len());
let before_text = ui_state.prompt_plain_text();
let before_cursor = ui_state.prompt_cursor();
let before_selection = ui_state.prompt_selection_text();
let before_revision = ui_state.prompt_content_revision();
let mut pending_input_events = VecDeque::from([TerminalInputEvent::KeyBurstTooLarge]);
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
let mut drain_outcome = DrainOutcome::default();
drain_outcome
.result
.startup_critical_loaded
.push((1, Ok(test_startup_critical())));
let mut autocomplete_candidates = Vec::new();
app.process_post_drain(
&mut ui_state,
&mut drain_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert_eq!(
app.startup_input_boundary,
Some(StartupInputBoundary { remaining: 1 })
);
assert_eq!(app.deferred_startup_critical_loaded.len(), 1);
let event = pending_input_events.pop_front().unwrap();
app.consume_startup_input_boundary_event();
assert!(apply_prompt_key_burst_event(
event,
&mut ui_state,
test_area(),
&autocomplete_candidates,
));
assert_eq!(ui_state.status, state::PROMPT_TOO_LARGE_STATUS);
assert_eq!(ui_state.prompt_plain_text(), before_text);
assert_eq!(ui_state.prompt_cursor(), before_cursor);
assert_eq!(ui_state.prompt_selection_text(), before_selection);
assert_eq!(ui_state.prompt_content_revision(), before_revision);
assert_eq!(
app.pending_startup_prompt
.as_ref()
.map(|pending| pending.prompt.as_str()),
Some("queued prompt")
);
assert_eq!(
app.startup_input_boundary,
Some(StartupInputBoundary { remaining: 0 })
);
assert_eq!(app.startup_readiness, StartupReadiness::Loading);
assert!(!app.active_run);
assert!(app.worker.is_none());
let mut readiness_outcome = DrainOutcome::default();
app.process_post_drain(
&mut ui_state,
&mut readiness_outcome,
&mut autocomplete_candidates,
PostDrainContext {
receiver: &receiver,
input_events: &input_events,
terminal_area: test_area(),
pending_input_events: &mut pending_input_events,
drain_budget_state: &mut drain_budget_state,
finish_completed_worker: true,
},
)
.unwrap();
assert_eq!(app.startup_readiness, StartupReadiness::Ready);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
test_area(),
&input_events.receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut last_area = test_area();
let mut redraw_intent = RedrawIntent::None;
draw_frame(
&mut terminal,
&mut ui_state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut drain_budget_state,
)
.unwrap();
app.mark_pending_startup_prompt_painted();
let (_fence_sender, fence_receiver) = bounded(1);
app.initial_prompt_flush_ack = Some(fence_receiver);
assert!(
app.submit_initial_prompt_if_ready(
&mut ui_state,
&receiver,
last_area,
&input_events.receiver,
&mut pending_input_events,
)
.unwrap()
.is_none()
);
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[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()),
&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();
crate::sessions::record_session_title(
&target,
temp.path(),
"Target title",
"provider",
"model",
)
.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 (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state.herdr_reporter = Some(owner.reporter());
let loaded = start_session_switch_load(
&app.state.session_manager,
app.state.config.as_ref(),
target.id(),
)
.unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 0,
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!(
app.pending_critical_events.pop_front(),
Some(TuiEvent::RunFinished {
worker_id: _,
final_event: None,
})
));
assert!(receiver.is_empty());
assert!(app.pending_session_switch.is_none());
app.state.herdr_reporter = None;
assert!(app.submit(
"follow-up prompt".to_string(),
&mut ui_state,
&receiver,
test_area()
));
assert!(app.active_run);
assert!(app.worker.is_some());
app.cleanup_after_run().unwrap();
let lines = lines.lock().unwrap();
assert_eq!(lines.len(), 2);
let session_report: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(session_report["method"], "pane.report_agent_session");
assert_eq!(session_report["params"]["agent_session_id"], target.id());
assert_eq!(session_report["params"]["session_start_source"], "select");
let title_report: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
assert_eq!(title_report["method"], "pane.report_metadata");
assert_eq!(title_report["params"]["title"], "Target title");
}
#[test]
fn stale_title_update_is_not_reported_for_a_replaced_session() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let active = app.state.session_manager.open("active-session").unwrap();
app.state.current_session = Some(active.clone());
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state.herdr_reporter = Some(owner.reporter());
let mut ui_state = state::MissionControlState::default();
let mut stale = DrainResult::default();
apply_tui_event_to_state(
&mut ui_state,
TuiEvent::SessionTitleUpdated {
session_id: "stale-session".to_string(),
title: "stale title".to_string(),
},
&mut stale,
);
app.handle_session_title_drain(&mut ui_state, &stale);
assert!(lines.lock().unwrap().is_empty());
let mut current = DrainResult::default();
apply_tui_event_to_state(
&mut ui_state,
TuiEvent::SessionTitleUpdated {
session_id: active.id().to_string(),
title: "current title".to_string(),
},
&mut current,
);
app.handle_session_title_drain(&mut ui_state, ¤t);
let lines = lines.lock().unwrap();
assert_eq!(lines.len(), 1);
let report: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(report["method"], "pane.report_metadata");
assert_eq!(report["params"]["title"], "current title");
}
#[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());
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state = app
.state
.clone()
.with_herdr_reporter(Some(owner.reporter()));
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()]);
assert!(lines.lock().unwrap().is_empty());
assert!(app.pending_session_switch.is_none());
assert!(!app.submit("/new".to_string(), &mut ui_state, &receiver, test_area()));
assert!(ui_state.status.starts_with("new session: "));
}
#[test]
fn session_switch_result_with_stale_request_id_keeps_current_pending_switch() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let pending = PendingSessionSwitch {
request_id: 2,
session_id: "current-session".to_string(),
generation: app.session_generation,
};
app.pending_session_switch = Some(pending.clone());
let mut ui_state = state::MissionControlState {
status: "still loading".to_string(),
..Default::default()
};
let mut result = DrainResult::default();
result.session_switch_loaded.push((
1,
pending.session_id.clone(),
Err("stale result".to_string()),
));
assert!(!app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert_eq!(app.pending_session_switch, Some(pending));
assert_eq!(ui_state.status, "still loading");
}
#[test]
fn session_switch_result_with_stale_session_id_keeps_current_pending_switch() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let pending = PendingSessionSwitch {
request_id: 2,
session_id: "current-session".to_string(),
generation: app.session_generation,
};
app.pending_session_switch = Some(pending.clone());
let mut ui_state = state::MissionControlState {
status: "still loading".to_string(),
..Default::default()
};
let mut result = DrainResult::default();
result.session_switch_loaded.push((
pending.request_id,
"stale-session".to_string(),
Err("stale result".to_string()),
));
assert!(!app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert_eq!(app.pending_session_switch, Some(pending));
assert_eq!(ui_state.status, "still loading");
}
#[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 footer_branch_refresh_does_not_queue_duplicate_pending_request() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.request_footer_git_branch_refresh();
let request_id = app.next_footer_git_branch_request_id;
assert_eq!(request_id, 1);
assert_eq!(app.pending_footer_git_branch_request_id, Some(request_id));
app.request_footer_git_branch_refresh();
assert_eq!(app.next_footer_git_branch_request_id, request_id);
assert_eq!(app.pending_footer_git_branch_request_id, Some(request_id));
let event = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
let mut ui_state = state::MissionControlState::default();
let mut drain_result = DrainResult::default();
apply_control_event_to_state(&mut ui_state, event, &mut drain_result);
assert!(drain_result.footer_git_branch_loaded.is_empty());
assert!(!app.handle_footer_git_branch_drain(&mut ui_state, &drain_result));
assert!(app.pending_footer_git_branch_request_id.is_none());
assert!(receiver.try_recv().is_err());
}
#[test]
fn footer_branch_refresh_recovers_without_wake_delivery() {
for disconnected in [false, true] {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".git")).unwrap();
std::fs::write(temp.path().join(".git/HEAD"), "ref: refs/heads/recovered\n").unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::WorkerOutcomeReady).unwrap();
let receiver = if disconnected { None } else { Some(receiver) };
let mut app = test_app(&temp, sender);
app.request_footer_git_branch_refresh();
let deadline = Instant::now() + Duration::from_secs(1);
while !app.footer_git_branch_worker.as_ref().unwrap().is_finished() {
assert!(Instant::now() < deadline);
thread::yield_now();
}
let mut ui_state = state::MissionControlState::default();
assert!(app.handle_footer_git_branch_drain(&mut ui_state, &DrainResult::default()));
assert_eq!(ui_state.footer_git_branch.as_deref(), Some("recovered"));
assert!(app.pending_footer_git_branch_request_id.is_none());
assert!(app.footer_git_branch_worker.is_none());
app.request_footer_git_branch_refresh();
assert_eq!(app.next_footer_git_branch_request_id, 2);
drop(receiver);
app.cleanup_after_run().unwrap();
assert!(app.footer_git_branch_worker.is_none());
assert!(app.pending_footer_git_branch_request_id.is_none());
}
}
#[test]
fn retained_critical_snapshot_is_bounded_and_drains_before_refilled_queue() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for worker_id in 0..4 {
sender
.try_send(TuiEvent::SessionTitleUpdated {
session_id: worker_id.to_string(),
title: "retained".into(),
})
.unwrap();
}
let mut pending = VecDeque::new();
preserve_critical_tui_events(&receiver, &mut pending);
for worker_id in 4..8 {
sender
.try_send(TuiEvent::SessionTitleUpdated {
session_id: worker_id.to_string(),
title: "live".into(),
})
.unwrap();
}
preserve_critical_tui_events(&receiver, &mut pending);
assert_eq!(pending.len(), 4);
assert_eq!(receiver.len(), 4);
let mut ui_state = state::MissionControlState::default();
let mut seen = Vec::new();
for _ in 0..4 {
let drained = drain_tui_events_with_pending(
&receiver,
&mut pending,
&mut ui_state,
DrainBudget {
max_events: 2,
max_elapsed: Duration::from_secs(1),
},
);
assert_eq!(drained.processed, 2);
seen.extend(
drained
.result
.session_title_updated
.into_iter()
.map(|(id, _)| id),
);
}
assert_eq!(seen, (0..8).map(|id| id.to_string()).collect::<Vec<_>>());
assert!(pending.is_empty());
assert!(receiver.is_empty());
}
#[test]
fn retained_critical_drain_obeys_elapsed_budget_before_live_queue() {
let (sender, receiver) = bounded::<TuiEvent>(1);
sender
.try_send(TuiEvent::SessionTitleUpdated {
session_id: "3".into(),
title: "live".into(),
})
.unwrap();
let mut pending = VecDeque::from([
TuiEvent::SessionTitleUpdated {
session_id: "1".into(),
title: "first".into(),
},
TuiEvent::SessionTitleUpdated {
session_id: "2".into(),
title: "second".into(),
},
]);
let drained = drain_tui_events_with_pending(
&receiver,
&mut pending,
&mut state::MissionControlState::default(),
DrainBudget {
max_events: 128,
max_elapsed: Duration::ZERO,
},
);
assert!(drained.exhausted);
assert_eq!(
drained.result.session_title_updated,
vec![("1".into(), "first".into())]
);
assert_eq!(pending.len(), 1);
assert_eq!(receiver.len(), 1);
}
#[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),
},
);
if budget_state.apply_outcome(&outcome) {
redraw_intent.request_immediate();
}
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 input_paint_marker_keeps_earliest_coalesced_edit() {
let first = Instant::now();
let second = first + Duration::from_millis(1);
let mut redraw_intent = RedrawIntent::None;
let mut budget_state = DrainLoopBudgetState::default();
budget_state.request_input_paint_at(&mut redraw_intent, second);
budget_state.request_input_paint_at(&mut redraw_intent, first);
assert_eq!(budget_state.input_paint_started, Some(first));
assert!(budget_state.input_paint_barrier_pending());
assert_eq!(budget_state.take_input_paint_started(), Some(first));
assert_eq!(budget_state.input_paint_started, None);
}
#[test]
fn input_receipt_to_completed_test_backend_draw_stays_below_ci_ceiling() {
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,
perf: crate::tui::perf::TuiPerfCounters::with_enabled(true),
..Default::default()
};
let input_received_at = Instant::now();
let prompt_revision_before = ui_state.prompt_content_revision();
let _ = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert_ne!(ui_state.prompt_content_revision(), prompt_revision_before);
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
let mut redraw_intent = RedrawIntent::None;
let mut budget_state = DrainLoopBudgetState::default();
budget_state.request_input_paint(
&mut redraw_intent,
tui_perf_enabled(&ui_state),
input_received_at,
);
assert!(budget_state.input_paint_started.is_some());
let post_drain = drain_tui_events_budgeted(&receiver, &mut ui_state, UI_DRAIN_BUDGET);
assert!(post_drain.result.changed);
if budget_state.apply_outcome(&post_drain) {
redraw_intent.request_immediate();
}
let mut last_area = ratatui::layout::Rect::new(0, 0, 80, 24);
draw_frame(
&mut terminal,
&mut ui_state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut budget_state,
)
.unwrap();
assert_eq!(budget_state.input_paint_started, None);
let snapshot = ui_state.perf.latest_snapshot().unwrap();
assert_eq!(ui_state.status, "run complete");
assert!(
snapshot.last_prompt_edit_to_draw_us < 50_000,
"input receipt took {} µs through post-drain completed TestBackend draw",
snapshot.last_prompt_edit_to_draw_us
);
}
#[test]
fn prompt_mutation_paints_before_control_drain() {
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 input_received_at = Instant::now();
let prompt_revision_before = ui_state.prompt_content_revision();
let _ = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert_ne!(ui_state.prompt_content_revision(), prompt_revision_before);
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
let mut redraw_intent = RedrawIntent::None;
let mut budget_state = DrainLoopBudgetState::default();
if ui_state.prompt_content_revision() != prompt_revision_before {
budget_state.request_input_paint(
&mut redraw_intent,
tui_perf_enabled(&ui_state),
input_received_at,
);
}
assert!(budget_state.input_paint_barrier_pending());
assert!(should_draw_frame(redraw_intent));
let mut last_area = ratatui::layout::Rect::new(0, 0, 80, 24);
let mut operations = Vec::new();
if budget_state.input_paint_barrier_pending() {
draw_frame(
&mut terminal,
&mut ui_state,
&mut last_area,
&receiver,
&mut redraw_intent,
&mut budget_state,
)
.unwrap();
operations.push("draw");
}
operations.push("control-drain");
let outcome = drain_tui_events_budgeted(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 1,
max_elapsed: Duration::from_secs(1),
},
);
assert_eq!(operations, ["draw", "control-drain"]);
assert!(outcome.result.changed);
assert_eq!(ui_state.status, "run complete");
assert!(!budget_state.input_paint_barrier_pending());
}
#[test]
fn reducer_exit_returns_exit_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, &mut ui_state, &mut context);
assert!(matches!(result, actions::AppControlFlow::Exit));
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn reducer_exit_defers_provider_join_to_post_terminal_cleanup() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let cancel = Arc::new(AtomicBool::new(false));
app.active_run = true;
app.worker = Some(WorkerState {
shutdown_policy: WorkerShutdownPolicy::Cancel,
handle: thread::spawn(|| {}),
cancel: Arc::clone(&cancel),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
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, &mut ui_state, &mut context);
assert!(matches!(result, actions::AppControlFlow::Exit));
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.active_run);
assert!(app.worker.is_some());
app.cleanup_after_run().unwrap();
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,
&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,
&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_connect_provider_closes_custom_form() {
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.open_connect_provider(Vec::new(), None);
ui_state.start_connect_custom_configuration();
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::CancelConnectProvider,
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
assert!(ui_state.modals.connect_provider.is_none());
assert_eq!(
ui_state.status,
"provider connection cancelled; settings unchanged"
);
}
#[test]
fn reducer_cancel_connect_provider_replacement_returns_to_form() {
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.open_connect_provider(Vec::new(), None);
ui_state.start_connect_custom_configuration();
ui_state.open_connect_provider_replacement_confirmation(
"local-ai".to_string(),
"Old Local AI".to_string(),
"New Local AI".to_string(),
"http://localhost:9090/v1".to_string(),
None,
);
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::CancelConnectProviderReplacement,
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
let connect = ui_state.modals.connect_provider.as_ref().unwrap();
assert_eq!(connect.stage, state::ConnectProviderStage::ConfigureCustom);
assert!(connect.replacement.is_none());
assert_eq!(
ui_state.status,
"custom provider replacement cancelled; edit the form or cancel"
);
}
fn finish_settings_save(app: &mut MissionControlApp, ui: &mut state::MissionControlState) {
let deadline = Instant::now() + Duration::from_secs(5);
while app.settings_persistence.is_pending() {
app.reap_settings_persistence_worker(ui);
assert!(Instant::now() < deadline, "settings worker did not finish");
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn session_maintenance_lists_without_event_delivery_and_ignores_closed_picker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
drop(receiver);
let mut ui = state::MissionControlState::default();
app.open_sessions_modal(&mut ui, ratatui::layout::Rect::new(0, 0, 80, 24));
assert_eq!(ui.status, "loading sessions…");
app.open_sessions_modal(&mut ui, ratatui::layout::Rect::new(0, 0, 80, 24));
assert_eq!(ui.status, "session maintenance already in progress");
ui.close_session_picker();
ui.status = "closed".into();
let deadline = Instant::now() + Duration::from_secs(5);
while app.session_maintenance.is_pending() {
app.reap_session_maintenance(&mut ui);
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(1));
}
assert_eq!(ui.status, "closed");
assert!(ui.modals.session_picker.is_none());
app.open_sessions_modal(&mut ui, ratatui::layout::Rect::new(0, 0, 80, 24));
let deadline = Instant::now() + Duration::from_secs(5);
while !app.reap_session_maintenance(&mut ui) {
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(1));
}
assert_eq!(ui.status, "select session");
assert!(ui.modals.session_picker.is_some());
}
#[test]
fn session_maintenance_prune_errors_and_session_switch_guard() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut ui = state::MissionControlState::default();
app.start_session_maintenance(Some("invalid".into()), None, &mut ui);
app.start_session_switch_worker("other-session".into(), &mut ui);
assert_eq!(ui.status, "session pruning in progress; please wait");
assert!(app.pending_session_switch.is_none());
let deadline = Instant::now() + Duration::from_secs(5);
while !app.reap_session_maintenance(&mut ui) {
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(1));
}
assert!(ui.status.contains("usage: /prune-sessions [days]"));
assert!(!app.session_maintenance.is_pruning());
}
#[test]
fn settings_save_does_not_wait_for_derived_settings_lock_and_rejects_overlap() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
drop(receiver); let tools = Arc::clone(&app.disabled_tools);
let held = tools.lock().unwrap();
let mut ui = state::MissionControlState::default();
app.set_tool_enabled("read", false, &mut ui);
assert!(app.settings_persistence.is_pending());
assert!(!app.reap_settings_persistence_worker(&mut ui));
app.set_tool_enabled("write", false, &mut ui);
assert_eq!(ui.status, "settings persistence already in progress");
drop(held);
finish_settings_save(&mut app, &mut ui);
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let disabled = crate::config::disabled_tool_names_from_settings(&settings);
assert!(disabled.contains("read"));
assert!(!disabled.contains("write"));
assert!(tools.lock().unwrap().contains("read"));
}
#[test]
fn settings_failure_rolls_back_rows_and_is_reported_at_cleanup() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
app.config.paths.settings_file = temp.path().to_path_buf();
let mut ui = state::MissionControlState::default();
ui.open_models_modal_scoped(
vec![state::ModelToggleRow {
id: "openai-codex/gpt-a".into(),
provider: "openai-codex".into(),
display_name: "gpt-a".into(),
enabled: false,
}],
5,
crate::config::SettingsScope::Global,
"Global".into(),
false,
);
app.set_model_enabled("openai-codex/gpt-a", false, &mut ui);
finish_settings_save(&mut app, &mut ui);
assert!(ui.modals.models_modal.as_ref().unwrap().rows[0].enabled);
assert!(ui.status.contains("failed to save setting"));
app.set_model_enabled("openai-codex/gpt-a", false, &mut ui);
assert!(ui.status.contains("restart Mission Control"));
assert!(!app.settings_persistence.is_pending());
assert!(app.cleanup_after_run().is_err());
}
#[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::DiscoveredSkill {
name: "demo".to_string(),
path: temp.path().join("demo/SKILL.md"),
frontmatter: std::collections::BTreeMap::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: false,
},
&mut ui_state,
&mut context,
);
assert!(matches!(result, actions::AppControlFlow::Continue));
finish_settings_save(&mut app, &mut ui_state);
refresh_skill_autocomplete_candidates(&mut autocomplete_candidates, &app.skills);
assert!(!autocomplete_candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::SkillTag && candidate.name == "demo"
}));
}
#[test]
fn cleanup_after_run_cancels_and_joins_provider_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
app.active_run = true;
app.worker = Some(WorkerState {
shutdown_policy: WorkerShutdownPolicy::Cancel,
handle: thread::spawn(move || {
while !worker_cancel.load(Ordering::SeqCst) {
thread::yield_now();
}
}),
cancel: Arc::clone(&cancel),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
app.cleanup_after_run().unwrap();
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.active_run);
assert!(app.worker.is_none());
}
use std::sync::Mutex;
#[test]
fn cleanup_joins_theme_worker_after_terminal_restoration() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let calls = Arc::new(Mutex::new(Vec::new()));
let guard =
crate::tui::terminal::test_guard_with_restore_probe(Arc::clone(&calls)).unwrap();
let worker_calls = Arc::clone(&calls);
app.theme_workers.push(ThemeWorker {
request_id: 1,
kind: ThemeWorkerKind::Catalog,
handle: thread::spawn(move || {
while !worker_calls.lock().unwrap().contains(&"terminal restored") {
thread::yield_now();
}
worker_calls.lock().unwrap().push("theme worker ran");
ThemeWorkerResult { delivery: Ok(()) }
}),
});
drop(guard);
app.cleanup_after_run().unwrap();
let calls = calls.lock().unwrap();
let restored = calls
.iter()
.position(|call| *call == "terminal restored")
.unwrap();
let worker = calls
.iter()
.position(|call| *call == "theme worker ran")
.unwrap();
assert!(
worker > restored,
"theme worker joined before terminal restoration: {calls:?}"
);
}
#[test]
fn rejected_idle_command_restores_exact_prompt_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("/compact", 3),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let input::InputAction::Submit(prompt) = input::take_prompt_action(&mut ui_state) else {
panic!("expected submit action");
};
assert!(ui_state.prompt_plain_text().is_empty());
assert!(!app.submit(prompt, &mut ui_state, &receiver, test_area()));
assert_eq!(ui_state.prompt_plain_text(), "/compact");
assert_eq!(ui_state.prompt_cursor(), 3);
assert_eq!(
ui_state.transcript.back().map(String::as_str),
Some("error: cannot compact while a prompt is running")
);
assert!(receiver.try_recv().is_err());
}
#[test]
fn accepted_command_commits_prompt_snapshot() {
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 {
prompt_editor: state::PromptEditor::new("/help", 2),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let input::InputAction::Submit(prompt) = input::take_prompt_action(&mut ui_state) else {
panic!("expected submit action");
};
assert!(!app.submit(prompt, &mut ui_state, &receiver, test_area()));
assert!(ui_state.show_help);
ui_state.restore_rejected_prompt("fallback".to_string());
assert_eq!(ui_state.prompt_plain_text(), "fallback");
}
#[test]
fn submit_defers_finished_worker_until_bounded_reconciliation() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender.clone());
let cancel = Arc::new(AtomicBool::new(false));
let outcome = Arc::new(WorkerOutcomeState::default());
let handle = thread::spawn(|| {});
while !handle.is_finished() {
thread::yield_now();
}
app.active_run = true;
send_completion(&sender, &outcome, None);
app.worker = Some(WorkerState {
shutdown_policy: WorkerShutdownPolicy::Cancel,
handle,
cancel,
login_manual: None,
outcome,
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState {
prompt_editor: state::PromptEditor::new("next prompt", "next prompt".len()),
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
ui_state.start_running_prompt("previous prompt".to_string());
let input::InputAction::Submit(prompt) = input::take_prompt_action(&mut ui_state) else {
panic!("expected prompt submission");
};
assert!(!app.submit(prompt, &mut ui_state, &receiver, test_area()));
assert_eq!(ui_state.prompt_plain_text(), "next prompt");
assert_eq!(ui_state.prompt_cursor(), "next prompt".len());
assert_eq!(
ui_state.status,
"previous prompt worker still shutting down"
);
assert!(ui_state.running_prompt.is_some());
assert!(app.worker.is_some());
assert!(app.active_run);
assert!(matches!(
receiver.try_recv(),
Ok(TuiEvent::RunFinished { .. })
));
app.cleanup_after_run().unwrap();
}
#[test]
fn submit_succeeds_after_bounded_reconciliation_finishes_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender.clone());
let cancel = Arc::new(AtomicBool::new(false));
let outcome = Arc::new(WorkerOutcomeState::default());
let worker_id = outcome.worker_id();
let handle = thread::spawn(|| {});
while !handle.is_finished() {
thread::yield_now();
}
app.active_run = true;
send_completion(&sender, &outcome, None);
app.worker = Some(WorkerState {
shutdown_policy: WorkerShutdownPolicy::Cancel,
handle,
cancel,
login_manual: None,
outcome,
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState {
active_worker_id: Some(worker_id),
..Default::default()
};
let drain = drain_tui_events_budgeted(&receiver, &mut ui_state, UI_DRAIN_BUDGET);
assert!(drain.result.run_finished);
assert!(app.join_completed_worker(&mut ui_state));
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(app.submit(
"next prompt".to_string(),
&mut ui_state,
&receiver,
test_area()
));
assert!(app.worker.is_some());
assert!(app.active_run);
app.cleanup_after_run().unwrap();
}
#[test]
fn bounded_drain_keeps_old_worker_until_matching_completion_and_ignores_stale_marker() {
let temp = tempfile::TempDir::new().unwrap();
let capacity = UI_DRAIN_BUDGET.max_events + 3;
let (sender, receiver) = bounded::<TuiEvent>(capacity);
let old_outcome = Arc::new(WorkerOutcomeState::default());
let old_worker_outcome = Arc::clone(&old_outcome);
for _ in 0..=UI_DRAIN_BUDGET.max_events {
sender.send(TuiEvent::Done).unwrap();
}
let old_sender = sender.clone();
let old_handle = thread::spawn(move || {
send_completion(&old_sender, &old_worker_outcome, None);
});
while !old_handle.is_finished() {
thread::yield_now();
}
let old_worker_id = old_outcome.worker_id();
let mut app = test_app(&temp, sender.clone());
app.active_run = true;
app.worker = Some(WorkerState {
shutdown_policy: WorkerShutdownPolicy::Cancel,
handle: old_handle,
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: old_outcome,
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState::default();
ui_state.start_running_prompt("old prompt".to_string());
ui_state.active_worker_id = Some(old_worker_id);
let input_events = TerminalInputBridge::spawn();
let mut drain_budget_state = DrainLoopBudgetState::default();
let mut first = drain_tui_events_budgeted(&receiver, &mut ui_state, UI_DRAIN_BUDGET);
assert_eq!(first.processed, UI_DRAIN_BUDGET.max_events);
assert!(!first.result.run_finished);
process_test_post_drain(
&mut app,
&mut ui_state,
&receiver,
&input_events,
&mut drain_budget_state,
&mut first,
);
assert!(app.worker.is_some());
assert!(app.active_run);
ui_state.set_prompt_text("next prompt", "next prompt".len());
assert!(!app.submit(
"next prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
));
assert!(app.worker.is_some());
assert!(app.active_run);
let mut second = drain_tui_events_budgeted(&receiver, &mut ui_state, UI_DRAIN_BUDGET);
assert!(second.result.run_finished);
assert_eq!(second.result.run_finished_worker_ids, vec![old_worker_id]);
process_test_post_drain(
&mut app,
&mut ui_state,
&receiver,
&input_events,
&mut drain_budget_state,
&mut second,
);
assert!(app.worker.is_none());
assert!(!app.active_run);
sender
.send(TuiEvent::RunFinished {
worker_id: old_worker_id,
final_event: Some(Box::new(TuiEvent::Error(
"stale old completion".to_string(),
))),
})
.unwrap();
assert!(app.submit(
"new prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
));
let new_worker_id = app.worker.as_ref().unwrap().outcome.worker_id();
assert_ne!(new_worker_id, old_worker_id);
assert!(app.active_run);
let mut stale = drain_tui_events_budgeted(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 1,
max_elapsed: Duration::from_secs(1),
},
);
assert_eq!(stale.processed, 1);
assert!(!stale.result.run_finished);
process_test_post_drain(
&mut app,
&mut ui_state,
&receiver,
&input_events,
&mut drain_budget_state,
&mut stale,
);
assert!(app.active_run);
assert_eq!(ui_state.active_worker_id, Some(new_worker_id));
assert_eq!(
ui_state
.running_prompt
.as_ref()
.map(|prompt| prompt.text.as_str()),
Some("new prompt")
);
app.cleanup_after_run().unwrap();
}
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 pending_global_save_blocks_scope_switch_until_inherited_rows_are_current() {
use crate::config::SettingsScope;
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::MissionControlState::default();
app.open_tools_modal(&mut ui, test_area());
let tools = Arc::clone(&app.disabled_tools);
let held = tools.lock().unwrap();
app.set_tool_enabled("read", false, &mut ui);
app.toggle_tools_settings_scope(&mut ui, test_area());
assert_eq!(ui.tools_modal_scope(), Some(SettingsScope::Global));
assert_eq!(ui.status, "settings save pending; scope change blocked");
drop(held);
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)).unwrap(),
TuiEvent::WorkerOutcomeReady
));
assert!(app.reap_settings_persistence_worker(&mut ui));
finish_settings_save(&mut app, &mut ui);
app.toggle_tools_settings_scope(&mut ui, test_area());
assert_eq!(ui.tools_modal_scope(), Some(SettingsScope::Project));
let modal = ui.modals.tools_modal.as_ref().unwrap();
assert!(
!modal
.rows
.iter()
.find(|row| row.name == "read")
.unwrap()
.enabled
);
}
#[test]
fn session_listing_wake_has_an_immediately_available_result() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui = state::MissionControlState::default();
app.open_sessions_modal(&mut ui, test_area());
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)).unwrap(),
TuiEvent::WorkerOutcomeReady
));
assert!(app.reap_session_maintenance(&mut ui));
assert_eq!(ui.status, "select session");
assert!(app.session_maintenance.cleanup().is_empty());
}
#[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);
finish_settings_save(&mut app, &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"
);
}
#[test]
fn selected_session_title_event_in_same_drain_is_not_reported_twice() {
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();
crate::sessions::record_session_title(
&target,
temp.path(),
"Target title",
"provider",
"model",
)
.unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender);
app.state.session_manager = manager;
app.state.current_session = Some(active);
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state.herdr_reporter = Some(owner.reporter());
app.pending_session_switch = Some(PendingSessionSwitch {
request_id: 1,
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();
let mut drain = DrainResult::default();
drain
.session_switch_loaded
.push((1, target.id().to_string(), Ok(loaded)));
drain
.session_title_updated
.push((target.id().to_string(), "Target title".to_string()));
let mut ui_state = state::MissionControlState::default();
assert!(app.handle_background_session_drain(&mut ui_state, &drain, &receiver));
let lines = lines.lock().unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&lines[0]).unwrap()["method"],
"pane.report_agent_session"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&lines[1]).unwrap()["method"],
"pane.report_metadata"
);
}
#[test]
fn failed_session_switch_emits_no_herdr_writes() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let active = app.state.session_manager.open("active-session").unwrap();
app.state.current_session = Some(active);
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state = app
.state
.clone()
.with_herdr_reporter(Some(owner.reporter()));
app.pending_session_switch = Some(PendingSessionSwitch {
request_id: 9,
session_id: "failed-session".to_string(),
generation: app.session_generation,
});
let mut ui_state = state::MissionControlState::default();
let mut result = DrainResult::default();
result.session_switch_loaded.push((
9,
"failed-session".to_string(),
Err("switch failed".to_string()),
));
assert!(app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert_eq!(ui_state.status, "switch failed");
assert!(lines.lock().unwrap().is_empty());
assert!(app.pending_session_switch.is_none());
assert!(!app.submit("/new".to_string(), &mut ui_state, &receiver, test_area()));
assert!(ui_state.status.starts_with("new session: "));
}
#[test]
fn matched_session_switch_canceled_by_export_releases_pending_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let pending = PendingSessionSwitch {
request_id: 4,
session_id: "target-session".to_string(),
generation: app.session_generation,
};
app.pending_session_switch = Some(pending.clone());
app.pending_export = Some(PendingExport {
request_id: 1,
session_id: "active-session".to_string(),
generation: app.session_generation,
});
let mut ui_state = state::MissionControlState::default();
let mut result = DrainResult::default();
result.session_switch_loaded.push((
pending.request_id,
pending.session_id,
Err("unused switch result".to_string()),
));
assert!(app.handle_session_switch_drain(&mut ui_state, &result, &receiver));
assert!(app.pending_session_switch.is_none());
assert_eq!(
ui_state.status,
"session export is in progress; session switch canceled"
);
}
#[test]
fn session_switch_preserves_events_with_disconnected_wake_queue() {
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 (app_sender, app_receiver) = bounded::<TuiEvent>(1);
drop(app_receiver);
let (preserved_sender, preserved_receiver) = bounded::<TuiEvent>(1);
preserved_sender
.send(TuiEvent::RunFinished {
worker_id: 0,
final_event: None,
})
.unwrap();
let mut app = test_app(&temp, app_sender);
app.state.session_manager = manager;
app.state.current_session = Some(active.clone());
let pending = PendingSessionSwitch {
request_id: 6,
session_id: target.id().to_string(),
generation: app.session_generation,
};
app.pending_session_switch = Some(pending.clone());
let loaded = start_session_switch_load(
&app.state.session_manager,
app.state.config.as_ref(),
target.id(),
)
.unwrap();
let mut result = DrainResult::default();
result
.session_switch_loaded
.push((pending.request_id, pending.session_id, Ok(loaded)));
let mut ui_state = state::MissionControlState::default();
assert!(app.handle_session_switch_drain(&mut ui_state, &result, &preserved_receiver));
assert!(app.pending_session_switch.is_none());
assert_eq!(app.state.active_session_id(), Some(target.id()));
assert!(preserved_receiver.is_empty());
assert!(matches!(
app.pending_critical_events.front(),
Some(TuiEvent::RunFinished {
worker_id: 0,
final_event: None
})
));
assert!(!app.submit(
"/new".to_string(),
&mut ui_state,
&preserved_receiver,
test_area()
));
assert!(ui_state.status.starts_with("new session: "));
}
#[test]
fn tui_new_session_reports_one_transition_and_one_title_clear() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let active = app.state.session_manager.open("active-session").unwrap();
app.state.current_session = Some(active);
let (owner, lines) = crate::herdr::HerdrOwner::new_for_test();
app.state = app
.state
.clone()
.with_herdr_reporter(Some(owner.reporter()));
let mut ui_state = state::MissionControlState::default();
assert!(!app.submit("/new".to_string(), &mut ui_state, &receiver, test_area()));
let lines = lines.lock().unwrap();
assert_eq!(lines.len(), 2);
let transition = serde_json::from_str::<serde_json::Value>(&lines[0]).unwrap();
let clear = serde_json::from_str::<serde_json::Value>(&lines[1]).unwrap();
assert_eq!(transition["method"], "pane.report_agent_session");
assert_eq!(transition["params"]["session_start_source"], "new");
assert_eq!(clear["method"], "pane.report_metadata");
assert_eq!(clear["params"]["clear_title"], true);
}
}