codex-helper-tui 0.12.1

Terminal UI crate for codex-helper.
Documentation
use ratatui::widgets::{ListState, TableState};

use crate::config::ResolvedRetryConfig;
use crate::sessions::{SessionMeta, SessionSummary, SessionTranscriptMessage};
use std::collections::HashMap;

use super::Language;
use super::model::{Snapshot, filtered_requests_len};
use super::types::{Focus, Overlay, Page, StatsFocus};

#[derive(Debug, Clone)]
pub(in crate::tui) struct RecentCodexRow {
    pub(in crate::tui) root: String,
    pub(in crate::tui) branch: Option<String>,
    pub(in crate::tui) session_id: String,
    pub(in crate::tui) cwd: Option<String>,
    pub(in crate::tui) mtime_ms: u64,
}

#[derive(Debug)]
pub(in crate::tui) struct UiState {
    pub(in crate::tui) service_name: &'static str,
    pub(in crate::tui) port: u16,
    pub(in crate::tui) language: Language,
    pub(in crate::tui) refresh_ms: u64,
    pub(in crate::tui) page: Page,
    pub(in crate::tui) focus: Focus,
    pub(in crate::tui) overlay: Overlay,
    pub(in crate::tui) selected_config_idx: usize,
    pub(in crate::tui) selected_session_idx: usize,
    pub(in crate::tui) selected_session_id: Option<String>,
    pub(in crate::tui) selected_request_idx: usize,
    pub(in crate::tui) selected_request_page_idx: usize,
    pub(in crate::tui) request_page_errors_only: bool,
    pub(in crate::tui) request_page_scope_session: bool,
    pub(in crate::tui) selected_sessions_page_idx: usize,
    pub(in crate::tui) sessions_page_active_only: bool,
    pub(in crate::tui) sessions_page_errors_only: bool,
    pub(in crate::tui) sessions_page_overrides_only: bool,
    pub(in crate::tui) effort_menu_idx: usize,
    pub(in crate::tui) provider_menu_idx: usize,
    pub(in crate::tui) stats_focus: StatsFocus,
    pub(in crate::tui) stats_days: usize,
    pub(in crate::tui) stats_errors_only: bool,
    pub(in crate::tui) selected_stats_config_idx: usize,
    pub(in crate::tui) selected_stats_provider_idx: usize,
    pub(in crate::tui) needs_snapshot_refresh: bool,
    pub(in crate::tui) toast: Option<(String, std::time::Instant)>,
    pub(in crate::tui) codex_history_sessions: Vec<SessionSummary>,
    pub(in crate::tui) codex_history_error: Option<String>,
    pub(in crate::tui) codex_history_loaded_at_ms: Option<u64>,
    pub(in crate::tui) needs_codex_history_refresh: bool,
    pub(in crate::tui) selected_codex_history_idx: usize,
    pub(in crate::tui) codex_recent_rows: Vec<RecentCodexRow>,
    pub(in crate::tui) codex_recent_error: Option<String>,
    pub(in crate::tui) codex_recent_loaded_at_ms: Option<u64>,
    pub(in crate::tui) needs_codex_recent_refresh: bool,
    pub(in crate::tui) codex_recent_window_idx: usize,
    pub(in crate::tui) codex_recent_selected_idx: usize,
    pub(in crate::tui) codex_recent_selected_id: Option<String>,
    pub(in crate::tui) codex_recent_raw_cwd: bool,
    pub(in crate::tui) codex_recent_branch_cache: HashMap<String, Option<String>>,
    pub(in crate::tui) session_transcript_meta: Option<SessionMeta>,
    pub(in crate::tui) session_transcript_sid: Option<String>,
    pub(in crate::tui) session_transcript_file: Option<String>,
    pub(in crate::tui) session_transcript_tail: Option<usize>,
    pub(in crate::tui) session_transcript_messages: Vec<SessionTranscriptMessage>,
    pub(in crate::tui) session_transcript_scroll: u16,
    pub(in crate::tui) session_transcript_error: Option<String>,
    pub(in crate::tui) pending_overwrite_from_codex_confirm_at: Option<std::time::Instant>,
    pub(in crate::tui) last_runtime_config_loaded_at_ms: Option<u64>,
    pub(in crate::tui) last_runtime_config_source_mtime_ms: Option<u64>,
    pub(in crate::tui) last_runtime_retry: Option<ResolvedRetryConfig>,
    pub(in crate::tui) last_runtime_config_refresh_at: Option<std::time::Instant>,
    pub(in crate::tui) should_exit: bool,
    pub(in crate::tui) configs_table: TableState,
    pub(in crate::tui) sessions_table: TableState,
    pub(in crate::tui) requests_table: TableState,
    pub(in crate::tui) request_page_table: TableState,
    pub(in crate::tui) sessions_page_table: TableState,
    pub(in crate::tui) codex_history_table: TableState,
    pub(in crate::tui) codex_recent_table: TableState,
    pub(in crate::tui) stats_configs_table: TableState,
    pub(in crate::tui) stats_providers_table: TableState,
    pub(in crate::tui) menu_list: ListState,
    pub(in crate::tui) config_info_scroll: u16,
}

impl Default for UiState {
    fn default() -> Self {
        Self {
            service_name: "codex",
            port: 3211,
            language: Language::En,
            refresh_ms: 500,
            page: Page::Dashboard,
            focus: Focus::Sessions,
            overlay: Overlay::None,
            selected_config_idx: 0,
            selected_session_idx: 0,
            selected_session_id: None,
            selected_request_idx: 0,
            selected_request_page_idx: 0,
            request_page_errors_only: false,
            request_page_scope_session: false,
            selected_sessions_page_idx: 0,
            sessions_page_active_only: false,
            sessions_page_errors_only: false,
            sessions_page_overrides_only: false,
            effort_menu_idx: 0,
            provider_menu_idx: 0,
            stats_focus: StatsFocus::Configs,
            stats_days: 21,
            stats_errors_only: false,
            selected_stats_config_idx: 0,
            selected_stats_provider_idx: 0,
            needs_snapshot_refresh: false,
            toast: None,
            codex_history_sessions: Vec::new(),
            codex_history_error: None,
            codex_history_loaded_at_ms: None,
            needs_codex_history_refresh: false,
            selected_codex_history_idx: 0,
            codex_recent_rows: Vec::new(),
            codex_recent_error: None,
            codex_recent_loaded_at_ms: None,
            needs_codex_recent_refresh: false,
            codex_recent_window_idx: 1,
            codex_recent_selected_idx: 0,
            codex_recent_selected_id: None,
            codex_recent_raw_cwd: false,
            codex_recent_branch_cache: HashMap::new(),
            session_transcript_meta: None,
            session_transcript_sid: None,
            session_transcript_file: None,
            session_transcript_tail: Some(80),
            session_transcript_messages: Vec::new(),
            session_transcript_scroll: 0,
            session_transcript_error: None,
            pending_overwrite_from_codex_confirm_at: None,
            last_runtime_config_loaded_at_ms: None,
            last_runtime_config_source_mtime_ms: None,
            last_runtime_retry: None,
            last_runtime_config_refresh_at: None,
            should_exit: false,
            configs_table: TableState::default(),
            sessions_table: TableState::default(),
            requests_table: TableState::default(),
            request_page_table: TableState::default(),
            sessions_page_table: TableState::default(),
            codex_history_table: TableState::default(),
            codex_recent_table: TableState::default(),
            stats_configs_table: TableState::default(),
            stats_providers_table: TableState::default(),
            menu_list: ListState::default(),
            config_info_scroll: 0,
        }
    }
}

impl UiState {
    pub(in crate::tui) fn clamp_selection(&mut self, snapshot: &Snapshot, providers_len: usize) {
        if providers_len == 0 {
            self.selected_config_idx = 0;
            self.configs_table.select(None);
        } else {
            self.selected_config_idx = self.selected_config_idx.min(providers_len - 1);
            self.configs_table.select(Some(self.selected_config_idx));
        }

        if snapshot.rows.is_empty() {
            self.selected_session_idx = 0;
            self.selected_session_id = None;
            self.sessions_table.select(None);

            self.selected_request_idx = 0;
            self.requests_table.select(None);
            return;
        }

        if let Some(sid) = self.selected_session_id.clone()
            && let Some(idx) = snapshot
                .rows
                .iter()
                .position(|r| r.session_id.as_deref() == Some(sid.as_str()))
        {
            self.selected_session_idx = idx;
        } else {
            self.selected_session_idx = self.selected_session_idx.min(snapshot.rows.len() - 1);
            self.selected_session_id = snapshot.rows[self.selected_session_idx].session_id.clone();
        }
        self.sessions_table.select(Some(self.selected_session_idx));

        let req_len = filtered_requests_len(snapshot, self.selected_session_idx);
        if req_len == 0 {
            self.selected_request_idx = 0;
            self.requests_table.select(None);
        } else {
            self.selected_request_idx = self.selected_request_idx.min(req_len - 1);
            self.requests_table.select(Some(self.selected_request_idx));
        }

        let stats_configs_len = snapshot.usage_rollup.by_config.len();
        if stats_configs_len == 0 {
            self.selected_stats_config_idx = 0;
            self.stats_configs_table.select(None);
        } else {
            self.selected_stats_config_idx =
                self.selected_stats_config_idx.min(stats_configs_len - 1);
            self.stats_configs_table
                .select(Some(self.selected_stats_config_idx));
        }

        let stats_providers_len = snapshot.usage_rollup.by_provider.len();
        if stats_providers_len == 0 {
            self.selected_stats_provider_idx = 0;
            self.stats_providers_table.select(None);
        } else {
            self.selected_stats_provider_idx = self
                .selected_stats_provider_idx
                .min(stats_providers_len - 1);
            self.stats_providers_table
                .select(Some(self.selected_stats_provider_idx));
        }
    }
}

pub(in crate::tui) fn adjust_table_selection(
    table: &mut TableState,
    delta: i32,
    len: usize,
) -> Option<usize> {
    if len == 0 {
        table.select(None);
        return None;
    }
    let cur = table.selected().unwrap_or(0);
    let next = if delta.is_negative() {
        cur.saturating_sub(delta.unsigned_abs() as usize)
    } else {
        (cur + delta as usize).min(len - 1)
    };
    table.select(Some(next));
    Some(next)
}