use super::*;
pub(crate) const UI_DRAIN_BUDGET: DrainBudget = DrainBudget {
max_events: 128,
max_elapsed: Duration::from_millis(2),
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DrainBudget {
pub(crate) max_events: usize,
pub(crate) max_elapsed: Duration,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct DrainOutcome {
pub(crate) result: DrainResult,
pub(crate) processed: usize,
pub(crate) exhausted: bool,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct DrainResult {
pub(crate) changed: bool,
pub(crate) run_finished: bool,
pub(crate) model_catalog_finished: bool,
pub(crate) model_catalog_loaded: Vec<(
u64,
Result<crate::model_catalog::CatalogForUi, String>,
usize,
)>,
pub(crate) usage_loaded: Vec<(u64, crate::tui::usage::UsageLoadResult)>,
pub(crate) startup_loaded: Vec<(u64, Result<TuiStartupLoaded, String>)>,
pub(crate) session_preview_loaded: Vec<(u64, String, state::SessionPreviewState)>,
pub(crate) session_switch_loaded: Vec<(u64, String, Result<SessionSwitchLoad, String>)>,
pub(crate) footer_git_branch_loaded: Vec<(u64, Option<String>)>,
}
impl PartialEq for DrainResult {
fn eq(&self, other: &Self) -> bool {
self.changed == other.changed
&& self.run_finished == other.run_finished
&& self.model_catalog_finished == other.model_catalog_finished
&& self.model_catalog_loaded.len() == other.model_catalog_loaded.len()
&& self.usage_loaded == other.usage_loaded
&& self.startup_loaded == other.startup_loaded
&& self.session_preview_loaded == other.session_preview_loaded
&& self.session_switch_loaded.len() == other.session_switch_loaded.len()
&& self
.session_switch_loaded
.iter()
.zip(&other.session_switch_loaded)
.all(|(left, right)| {
left.0 == right.0 && left.1 == right.1 && left.2.is_ok() == right.2.is_ok()
})
&& self.footer_git_branch_loaded == other.footer_git_branch_loaded
}
}
impl Eq for DrainResult {}
pub(crate) fn drain_tui_events(
receiver: &Receiver<TuiEvent>,
state: &mut state::MissionControlState,
) -> DrainResult {
let mut result = DrainResult::default();
while let Ok(event) = receiver.try_recv() {
apply_tui_event_to_state(state, event, &mut result);
}
result
}
pub(crate) fn drain_tui_events_budgeted(
receiver: &Receiver<TuiEvent>,
state: &mut state::MissionControlState,
budget: DrainBudget,
) -> DrainOutcome {
drain_tui_events_budgeted_with_clock(receiver, state, budget, Instant::now)
}
pub(crate) fn drain_tui_events_budgeted_with_clock(
receiver: &Receiver<TuiEvent>,
state: &mut state::MissionControlState,
budget: DrainBudget,
mut now: impl FnMut() -> Instant,
) -> DrainOutcome {
let started = now();
drain_tui_events_budgeted_from(receiver, state, budget, 0, started, now)
}
pub(crate) fn drain_tui_events_budgeted_from(
receiver: &Receiver<TuiEvent>,
state: &mut state::MissionControlState,
budget: DrainBudget,
already_processed: usize,
started: Instant,
mut now: impl FnMut() -> Instant,
) -> DrainOutcome {
let mut outcome = DrainOutcome {
processed: already_processed,
..Default::default()
};
if outcome.processed >= budget.max_events {
outcome.exhausted = true;
return outcome;
}
while outcome.processed < budget.max_events {
if outcome.processed > 0 && now().duration_since(started) >= budget.max_elapsed {
outcome.exhausted = true;
return outcome;
}
let Ok(event) = receiver.try_recv() else {
return outcome;
};
apply_tui_event_to_state(state, event, &mut outcome.result);
outcome.processed += 1;
}
outcome.exhausted = true;
outcome
}
pub(crate) fn apply_tui_event_to_state(
state: &mut state::MissionControlState,
event: TuiEvent,
result: &mut DrainResult,
) {
match event {
TuiEvent::Output(event) => apply_output_event_to_state(state, event, result),
TuiEvent::Activity(event) => apply_activity_event_to_state(state, event, result),
event => apply_control_event_to_state(state, event, result),
}
}
fn apply_output_event_to_state(
state: &mut state::MissionControlState,
event: OutputEvent,
result: &mut DrainResult,
) {
state.apply_output_event(&event);
result.changed = true;
}
pub(crate) fn apply_activity_event_to_state(
state: &mut state::MissionControlState,
event: ActivityEvent,
result: &mut DrainResult,
) {
state.apply_activity_event(event);
result.changed = true;
}
pub(crate) fn apply_control_event_to_state(
state: &mut state::MissionControlState,
event: TuiEvent,
result: &mut DrainResult,
) {
match event {
TuiEvent::Error(error) => {
state.finish_assistant_streaming();
state.record_error_transcript(&error);
state.status = error;
}
TuiEvent::ModelCatalog {
request_id,
result: catalog_result,
visible_rows,
} => {
if state.pending_model_catalog_request_id != Some(request_id) {
return;
}
state.pending_model_catalog_request_id = None;
result.model_catalog_finished = true;
result
.model_catalog_loaded
.push((request_id, catalog_result, visible_rows));
}
TuiEvent::UsageLoaded {
request_id,
result: usage_result,
} => {
if state.pending_usage_request_id != Some(request_id) || !state.usage_modal_visible() {
return;
}
state.pending_usage_request_id = None;
let status = match &usage_result {
crate::tui::usage::UsageLoadResult::Loaded(report)
if report.providers.is_empty() =>
{
"no provider usage data available"
}
crate::tui::usage::UsageLoadResult::Loaded(_) => "provider usage loaded",
crate::tui::usage::UsageLoadResult::MissingCli => "quota-axi is not installed",
crate::tui::usage::UsageLoadResult::Error(_) => "failed to load provider usage",
};
state.set_usage_modal_result(usage_result.clone());
state.status = status.to_string();
result.usage_loaded.push((request_id, usage_result));
}
TuiEvent::LoginInstructions(instructions) => {
state.set_login_instructions(instructions.url, instructions.message);
state.status =
"OpenAI Codex login waiting for browser callback or pasted redirect".to_string();
}
TuiEvent::CompactionFinishedAuthoritative {
result: outcome,
activity,
} => apply_worker_final_event(
state,
WorkerFinalEvent::CompactionFinished {
result: outcome,
activity,
},
),
TuiEvent::LoginFinished {
provider_id,
result: outcome,
} => match outcome {
Ok(message) => {
state.clear_active_login();
if state.provider == provider_id {
state.provider_ready = true;
}
state.status = message;
}
Err(error) => {
state.clear_active_login();
state.record_error_transcript(&error);
state.status = error;
}
},
TuiEvent::Done => {
state.finish_assistant_streaming();
state.status = "run complete".to_string();
}
TuiEvent::RunCanceled { prompt } => {
state.finish_assistant_streaming();
let preview = crate::tui::transcript::sanitize_preview(&prompt);
state.mark_running_prompt_canceled();
state.record_canceled_transcript(&preview);
state.status = format!("canceled: {preview}");
}
TuiEvent::RunFinished { final_event } => {
if let Some(event) = final_event {
apply_control_event_to_state(state, *event, result);
}
state.finish_assistant_streaming();
state.clear_running_prompt();
result.run_finished = true;
}
TuiEvent::SessionTitleUpdated { session_id, title } => {
state.apply_session_title_update(&session_id, &title);
}
TuiEvent::StartupLoaded {
request_id,
result: loaded,
} => {
if state.pending_startup_request_id != Some(request_id) {
return;
}
state.pending_startup_request_id = None;
match *loaded {
Ok(loaded) => result.startup_loaded.push((request_id, Ok(loaded))),
Err(error) => {
state.record_error_transcript(&error);
state.status = error;
}
}
}
TuiEvent::SessionPreviewLoaded {
request_id,
session_id,
preview,
} => {
result
.session_preview_loaded
.push((request_id, session_id, preview));
}
TuiEvent::SessionSwitchLoaded {
request_id,
session_id,
result: switch_result,
} => {
result
.session_switch_loaded
.push((request_id, session_id, *switch_result));
}
TuiEvent::FooterGitBranchLoaded { request_id, branch } => {
result.footer_git_branch_loaded.push((request_id, branch));
}
TuiEvent::Output(_) | TuiEvent::Activity(_) => {}
}
result.changed = true;
}
pub(crate) fn drain_stale_tui_events(receiver: &Receiver<TuiEvent>) -> DrainResult {
let mut result = DrainResult::default();
while let Ok(event) = receiver.try_recv() {
if matches!(event, TuiEvent::RunFinished { .. }) {
result.run_finished = true;
}
result.changed = true;
}
result
}
pub(crate) fn picker_visible_rows(list_area: Option<ratatui::layout::Rect>) -> usize {
list_area
.map(|area| area.height.max(1) as usize)
.unwrap_or(input::DEFAULT_PICKER_VISIBLE_ROWS as usize)
}