codex-helper-tui 0.15.0

Terminal UI crate for codex-helper.
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};

use super::model::{Palette, ProviderOption, Snapshot};
use super::state::UiState;
use super::types::Overlay;
use crate::tui::i18n::{self, msg};

mod chrome;
mod modals;
mod pages;
mod stats;
mod widgets;

pub(in crate::tui) fn render_app(
    f: &mut Frame<'_>,
    p: Palette,
    ui: &mut UiState,
    snapshot: &Snapshot,
    service_name: &'static str,
    port: u16,
    providers: &[ProviderOption],
) {
    f.render_widget(widgets::BackgroundWidget { p }, f.area());

    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(4),
            Constraint::Min(0),
            Constraint::Length(2),
        ])
        .split(f.area());

    chrome::render_header(f, p, ui, snapshot, service_name, port, outer[0]);
    pages::render_body(f, p, ui, snapshot, providers, outer[1]);
    chrome::render_footer(f, p, ui, outer[2]);

    match ui.overlay {
        Overlay::None => {}
        Overlay::Help => modals::render_help_modal(f, p, ui),
        Overlay::StationInfo => modals::render_station_info_modal(f, p, ui, snapshot, providers),
        Overlay::EffortMenu => modals::render_effort_modal(f, p, ui),
        Overlay::ModelMenuSession => modals::render_model_modal(f, p, ui),
        Overlay::ModelInputSession => modals::render_model_input_modal(f, p, ui),
        Overlay::ServiceTierMenuSession => modals::render_service_tier_modal(f, p, ui),
        Overlay::ServiceTierInputSession => modals::render_service_tier_input_modal(f, p, ui),
        Overlay::ProfileMenuSession
        | Overlay::ProfileMenuDefaultRuntime
        | Overlay::ProfileMenuDefaultPersisted => modals::render_profile_modal_v2(f, p, ui),
        Overlay::SessionTranscript => modals::render_session_transcript_modal(f, p, ui),
        Overlay::ProviderMenuSession | Overlay::ProviderMenuGlobal => {
            let title = match ui.overlay {
                Overlay::ProviderMenuSession if ui.uses_route_graph_routing() => {
                    i18n::label(ui.language, "session route target")
                }
                Overlay::ProviderMenuSession => {
                    i18n::text(ui.language, msg::OVERLAY_SESSION_PROVIDER_OVERRIDE)
                }
                Overlay::ProviderMenuGlobal if ui.uses_route_graph_routing() => {
                    i18n::label(ui.language, "global route target")
                }
                Overlay::ProviderMenuGlobal => {
                    i18n::text(ui.language, msg::OVERLAY_GLOBAL_STATION_PIN)
                }
                _ => unreachable!(),
            };
            modals::render_provider_modal(f, p, ui, snapshot, providers, title);
        }
        Overlay::RoutingMenu => modals::render_routing_modal(f, p, ui, snapshot),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::{BTreeMap, HashMap};
    use std::time::Instant;

    use ratatui::Terminal;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;

    use crate::state::{BalanceSnapshotStatus, ProviderBalanceSnapshot, UsageBucket};
    use crate::tui::Language;
    use crate::tui::model::{RoutingProviderRef, RoutingSpecView, Snapshot};
    use crate::tui::types::{Page, StatsFocus};

    fn buffer_text(buffer: &Buffer) -> String {
        let mut out = String::new();
        for y in buffer.area.y..buffer.area.y.saturating_add(buffer.area.height) {
            for x in buffer.area.x..buffer.area.x.saturating_add(buffer.area.width) {
                out.push_str(buffer[(x, y)].symbol());
            }
            out.push('\n');
        }
        out
    }

    fn sample_snapshot() -> Snapshot {
        Snapshot {
            rows: Vec::new(),
            recent: Vec::new(),
            model_overrides: HashMap::new(),
            overrides: HashMap::new(),
            station_overrides: HashMap::new(),
            route_target_overrides: HashMap::new(),
            service_tier_overrides: HashMap::new(),
            global_station_override: None,
            global_route_target_override: Some("input-light".to_string()),
            station_meta_overrides: HashMap::new(),
            usage_rollup: crate::state::UsageRollupView {
                by_config: vec![(
                    "超级路由入口".to_string(),
                    UsageBucket {
                        requests_total: 7,
                        ..UsageBucket::default()
                    },
                )],
                by_provider: vec![
                    (
                        "input-light".to_string(),
                        UsageBucket {
                            requests_total: 5,
                            ..UsageBucket::default()
                        },
                    ),
                    (
                        "超级中转套餐年度输入提供商".to_string(),
                        UsageBucket {
                            requests_total: 2,
                            requests_error: 1,
                            ..UsageBucket::default()
                        },
                    ),
                ],
                ..crate::state::UsageRollupView::default()
            },
            provider_balances: HashMap::from([
                (
                    "input-light".to_string(),
                    vec![ProviderBalanceSnapshot {
                        provider_id: "input-light".to_string(),
                        status: BalanceSnapshotStatus::Exhausted,
                        exhausted: Some(true),
                        exhaustion_affects_routing: false,
                        quota_period: Some("daily".to_string()),
                        quota_remaining_usd: Some("0".to_string()),
                        quota_limit_usd: Some("300".to_string()),
                        ..ProviderBalanceSnapshot::default()
                    }],
                ),
                (
                    "超级中转套餐年度输入提供商".to_string(),
                    vec![ProviderBalanceSnapshot {
                        provider_id: "超级中转套餐年度输入提供商".to_string(),
                        status: BalanceSnapshotStatus::Stale,
                        error: Some("refresh balance failed".to_string()),
                        ..ProviderBalanceSnapshot::default()
                    }],
                ),
            ]),
            station_health: HashMap::new(),
            health_checks: HashMap::new(),
            lb_view: HashMap::new(),
            stats_5m: crate::dashboard_core::WindowStats::default(),
            stats_1h: crate::dashboard_core::WindowStats::default(),
            pricing_catalog: crate::pricing::bundled_model_price_catalog_snapshot(),
            refreshed_at: Instant::now(),
        }
    }

    fn sample_routing_spec() -> RoutingSpecView {
        let order = [
            "input",
            "input1",
            "input2",
            "input-light",
            "超级中转套餐年度输入提供商",
        ]
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();

        RoutingSpecView {
            entry: "main".to_string(),
            routes: BTreeMap::from([(
                "main".to_string(),
                crate::config::RoutingNodeV4 {
                    strategy: crate::config::RoutingPolicyV4::OrderedFailover,
                    children: order.clone(),
                    ..crate::config::RoutingNodeV4::default()
                },
            )]),
            policy: crate::config::RoutingPolicyV4::OrderedFailover,
            order: Vec::new(),
            target: None,
            prefer_tags: Vec::new(),
            chain: Vec::new(),
            pools: BTreeMap::new(),
            on_exhausted: crate::config::RoutingExhaustedActionV4::Continue,
            entry_strategy: crate::config::RoutingPolicyV4::OrderedFailover,
            expanded_order: order.clone(),
            entry_target: None,
            providers: order
                .iter()
                .map(|name| RoutingProviderRef {
                    name: name.clone(),
                    alias: None,
                    enabled: true,
                    tags: BTreeMap::new(),
                })
                .collect(),
        }
    }

    fn render_app_text(width: u16, height: u16, ui: &mut UiState, snapshot: &Snapshot) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("terminal");
        let frame = terminal
            .draw(|frame| {
                render_app(frame, Palette::default(), ui, snapshot, "codex", 18080, &[]);
            })
            .expect("draw");
        buffer_text(frame.buffer)
    }

    #[test]
    fn app_smoke_renders_usage_and_routing_at_normal_and_narrow_widths() {
        let snapshot = sample_snapshot();
        let routing_spec = sample_routing_spec();
        let cases = [
            (Page::Stats, 118, 32),
            (Page::Stats, 76, 24),
            (Page::Stations, 118, 32),
            (Page::Stations, 76, 24),
        ];

        for (page, width, height) in cases {
            let mut ui = UiState {
                page,
                config_version: Some(4),
                routing_spec: Some(routing_spec.clone()),
                selected_station_idx: 3,
                selected_stats_provider_idx: 0,
                stats_focus: StatsFocus::Providers,
                language: Language::Zh,
                ..UiState::default()
            };

            let text = render_app_text(width, height, &mut ui, &snapshot);

            assert!(text.contains("codex"), "{text}");
            assert!(text.contains("?") || text.contains("帮助"), "{text}");
            match page {
                Page::Stats => {
                    assert!(text.contains("Balance") || text.contains("余额"), "{text}");
                    assert!(
                        text.contains("input-light")
                            || (text.contains("") && text.contains("")),
                        "{text}"
                    );
                }
                Page::Stations => {
                    assert!(text.contains("entry route main"), "{text}");
                    assert!(text.contains("input-light"), "{text}");
                    assert!(text.contains("ordered-failover"), "{text}");
                }
                _ => unreachable!(),
            }
        }
    }
}