codex-helper-tui 0.12.1

Terminal UI crate for codex-helper.
Documentation
use std::time::Duration;

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::prelude::{Line, Modifier, Span, Style, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

use crate::tui::model::{Palette, Snapshot};
use crate::tui::state::UiState;
use crate::tui::types::{Focus, Overlay, Page, page_index, page_titles};

pub(super) fn render_header(
    f: &mut Frame<'_>,
    p: Palette,
    ui: &UiState,
    snapshot: &Snapshot,
    service_name: &'static str,
    port: u16,
    area: Rect,
) {
    let content_area = Rect {
        x: area.x,
        y: area.y,
        width: area.width,
        height: area.height.saturating_sub(1),
    };
    let inner = content_area.inner(Margin {
        horizontal: 1,
        vertical: 0,
    });
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(inner);

    let active_total = snapshot.rows.iter().map(|r| r.active_count).sum::<usize>();
    let recent_err = snapshot
        .recent
        .iter()
        .take(80)
        .filter(|r| r.status_code >= 400)
        .count();
    let updated = snapshot.refreshed_at.elapsed().as_millis();
    let overrides_effort = snapshot.overrides.len();
    let overrides_cfg = snapshot.config_overrides.len();
    let (hc_running, hc_canceling) = {
        let mut running = 0usize;
        let mut canceling = 0usize;
        for st in snapshot.health_checks.values() {
            if !st.done {
                running += 1;
                if st.cancel_requested {
                    canceling += 1;
                }
            }
        }
        (running, canceling)
    };

    let global_cfg = snapshot
        .global_override
        .as_deref()
        .filter(|s| !s.trim().is_empty())
        .unwrap_or("-");
    let focus = match ui.focus {
        Focus::Sessions => crate::tui::i18n::pick(ui.language, "会话", "Sessions"),
        Focus::Requests => crate::tui::i18n::pick(ui.language, "请求", "Requests"),
        Focus::Configs => crate::tui::i18n::pick(ui.language, "配置", "Configs"),
    };
    let title = Line::from(vec![
        Span::styled(
            "codex-helper",
            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
        ),
        Span::raw("  "),
        Span::styled(
            format!("{service_name}:{port}"),
            Style::default().fg(p.muted),
        ),
        Span::raw("  "),
        Span::styled(
            format!(
                "{}{focus}",
                crate::tui::i18n::pick(ui.language, "焦点:", "focus: ")
            ),
            Style::default().fg(p.muted),
        ),
    ]);

    let last_req = snapshot.recent.first();
    let last_provider = last_req
        .and_then(|r| r.provider_id.as_deref())
        .filter(|s| !s.trim().is_empty())
        .unwrap_or("-");
    let last_config = last_req
        .and_then(|r| r.config_name.as_deref())
        .filter(|s| !s.trim().is_empty())
        .unwrap_or("-");
    let last_attempts = last_req
        .and_then(|r| r.retry.as_ref())
        .map(|x| x.attempts)
        .unwrap_or(1);

    let fmt_ok_pct = |ok: usize, total: usize| -> String {
        if total == 0 {
            "-".to_string()
        } else {
            format!("{:>2}%", ((ok as f64) * 100.0 / (total as f64)).round())
        }
    };
    let fmt_ms = |ms: Option<u64>| -> String {
        ms.map(|m| format!("{m}ms"))
            .unwrap_or_else(|| "-".to_string())
    };
    let fmt_attempts = |a: Option<f64>| -> String {
        a.map(|v| format!("{v:.1}"))
            .unwrap_or_else(|| "-".to_string())
    };

    let s5 = &snapshot.stats_5m;
    let s1 = &snapshot.stats_1h;

    let subtitle = Line::from(vec![
        Span::styled(
            crate::tui::i18n::pick(ui.language, "活跃 ", "active "),
            Style::default().fg(p.muted),
        ),
        Span::styled(active_total.to_string(), Style::default().fg(p.good)),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "错误(80) ", "errors(80) "),
            Style::default().fg(p.muted),
        ),
        Span::styled(
            recent_err.to_string(),
            Style::default().fg(if recent_err > 0 { p.warn } else { p.muted }),
        ),
        Span::raw("   "),
        Span::styled("5m ", Style::default().fg(p.muted)),
        Span::styled(
            fmt_ok_pct(s5.ok_2xx, s5.total),
            Style::default().fg(if s5.total > 0 && s5.ok_2xx == s5.total {
                p.good
            } else {
                p.muted
            }),
        ),
        Span::raw(" "),
        Span::styled("p95 ", Style::default().fg(p.muted)),
        Span::styled(fmt_ms(s5.p95_ms), Style::default().fg(p.muted)),
        Span::raw(" "),
        Span::styled("att ", Style::default().fg(p.muted)),
        Span::styled(fmt_attempts(s5.avg_attempts), Style::default().fg(p.muted)),
        Span::raw(" "),
        Span::styled("429 ", Style::default().fg(p.muted)),
        Span::styled(
            s5.err_429.to_string(),
            Style::default().fg(if s5.err_429 > 0 { p.warn } else { p.muted }),
        ),
        Span::raw(" "),
        Span::styled("5xx ", Style::default().fg(p.muted)),
        Span::styled(
            s5.err_5xx.to_string(),
            Style::default().fg(if s5.err_5xx > 0 { p.warn } else { p.muted }),
        ),
        Span::raw("   "),
        Span::styled("1h ", Style::default().fg(p.muted)),
        Span::styled(
            fmt_ok_pct(s1.ok_2xx, s1.total),
            Style::default().fg(p.muted),
        ),
        Span::raw(" "),
        Span::styled("p95 ", Style::default().fg(p.muted)),
        Span::styled(fmt_ms(s1.p95_ms), Style::default().fg(p.muted)),
        Span::raw(" "),
        Span::styled("429 ", Style::default().fg(p.muted)),
        Span::styled(
            s1.err_429.to_string(),
            Style::default().fg(if s1.err_429 > 0 { p.warn } else { p.muted }),
        ),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "当前 ", "cur "),
            Style::default().fg(p.muted),
        ),
        Span::styled(
            format!("{last_provider}/{last_config}×{last_attempts}"),
            Style::default().fg(p.accent),
        ),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "健康检查 ", "hc "),
            Style::default().fg(p.muted),
        ),
        Span::styled(
            if hc_running > 0 {
                if ui.language == crate::tui::Language::Zh {
                    format!("运行:{hc_running} 取消:{hc_canceling}")
                } else {
                    format!("run:{hc_running} cancel:{hc_canceling}")
                }
            } else {
                "-".to_string()
            },
            Style::default().fg(if hc_running > 0 { p.accent } else { p.muted }),
        ),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "覆盖 ", "overrides "),
            Style::default().fg(p.muted),
        ),
        Span::styled(
            format!("{overrides_effort}/{overrides_cfg}"),
            Style::default().fg(p.muted),
        ),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "覆盖(全局) ", "override(global) "),
            Style::default().fg(p.muted),
        ),
        Span::styled(global_cfg.to_string(), Style::default().fg(p.accent)),
        Span::raw("   "),
        Span::styled(
            crate::tui::i18n::pick(ui.language, "刷新 ", "updated "),
            Style::default().fg(p.muted),
        ),
        Span::styled(format!("{updated}ms"), Style::default().fg(p.muted)),
    ]);

    let tabs = ratatui::widgets::Tabs::new(
        page_titles(ui.language)
            .iter()
            .map(|t| Line::from(*t))
            .collect::<Vec<_>>(),
    )
    .select(page_index(ui.page))
    .style(Style::default().fg(p.muted))
    .highlight_style(Style::default().fg(p.text).add_modifier(Modifier::BOLD))
    .divider(Span::raw("  "));

    let block = Block::default()
        .borders(Borders::BOTTOM)
        .border_style(Style::default().fg(p.border));
    f.render_widget(block, area);
    f.render_widget(Paragraph::new(Text::from(title)), chunks[0]);
    f.render_widget(Paragraph::new(Text::from(subtitle)), chunks[1]);
    f.render_widget(tabs, chunks[2]);
}

pub(super) fn render_footer(f: &mut Frame<'_>, p: Palette, ui: &mut UiState, area: Rect) {
    let now = std::time::Instant::now();
    if let Some((_, ts)) = ui.toast.as_ref()
        && now.duration_since(*ts) > Duration::from_secs(3)
    {
        ui.toast = None;
    }

    let left = match ui.overlay {
        Overlay::None => match ui.page {
            Page::Dashboard => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  Tab 焦点  ↑/↓ 或 j/k 移动  Enter effort  l/m/h/X 设置  x 清除  p 会话配置  P 全局配置  ? 帮助",
                "1-8 pages  q quit  L language  Tab focus  ↑/↓ or j/k move  Enter effort  l/m/h/X set  x clear  p session cfg  P global cfg  ? help",
            ),
            Page::Configs => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  ↑/↓ 选择  i 详情  t 切换 enabled  +/- level  h 检查  H 全部检查  c 取消  C 全部取消  Enter 设为 active  Backspace 自动  o 会话 override  O 清除  ? 帮助",
                "1-8 pages  q quit  L language  ↑/↓ select  i details  t toggle enabled  +/- level  h check  H check all  c cancel  C cancel all  Enter set active  Backspace auto  o session override  O clear  ? help",
            ),
            Page::Requests => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  ↑/↓ 选择  e 仅看错误  s scope(会话/全部)  ? 帮助",
                "1-8 pages  q quit  L language  ↑/↓ select  e errors_only  s scope(session/all)  ? help",
            ),
            Page::Sessions => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  ↑/↓ 选择  a 仅看活跃  e 仅看错误  v 仅看覆盖  r 重置  t 对话记录  ? 帮助",
                "1-8 pages  q quit  L language  ↑/↓ select  a active_only  e errors_only  v overrides_only  r reset  t transcript  ? help",
            ),
            Page::Stats => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  Tab 焦点(config/provider)  ↑/↓ 选择  d 天数(7/21/60)  e 仅看错误(recent)  y 复制+导出报告  ? 帮助",
                "1-8 pages  q quit  L language  Tab focus(config/provider)  ↑/↓ select  d days(7/21/60)  e errors_only(recent)  y copy+export report  ? help",
            ),
            Page::Settings => crate::tui::i18n::pick(
                ui.language,
                if ui.service_name == "codex" {
                    "1-8 页面  q 退出  L 语言  R 重载配置  O 覆盖导入(~/.codex,二次确认)  ? 帮助"
                } else {
                    "1-8 页面  q 退出  L 语言  R 重载配置  ? 帮助"
                },
                if ui.service_name == "codex" {
                    "1-8 pages  q quit  L language  R reload  O overwrite(~/.codex, confirm)  ? help"
                } else {
                    "1-8 pages  q quit  L language  R reload  ? help"
                },
            ),
            Page::History => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  ↑/↓ 选择  r 刷新  t/Enter 对话记录  ? 帮助",
                "1-8 pages  q quit  L language  ↑/↓ select  r refresh  t/Enter transcript  ? help",
            ),
            Page::Recent => crate::tui::i18n::pick(
                ui.language,
                "1-8 页面  q 退出  L 语言  ↑/↓ 选择  [] 切换时间  r 刷新  Enter 复制选中  y 复制全部(可见)  ? 帮助",
                "1-8 pages  q quit  L language  ↑/↓ select  [] window  r refresh  Enter copy selected  y copy all(visible)  ? help",
            ),
        },
        Overlay::Help => crate::tui::i18n::pick(
            ui.language,
            "Esc 关闭帮助  L 语言",
            "Esc close help  L language",
        ),
        Overlay::EffortMenu => crate::tui::i18n::pick(
            ui.language,
            "↑/↓ 选择  Enter 应用  Esc 取消",
            "↑/↓ select  Enter apply  Esc cancel",
        ),
        Overlay::ProviderMenuSession | Overlay::ProviderMenuGlobal => crate::tui::i18n::pick(
            ui.language,
            "↑/↓ 选择  Enter 应用  Esc 取消",
            "↑/↓ select  Enter apply  Esc cancel",
        ),
        Overlay::ConfigInfo => crate::tui::i18n::pick(
            ui.language,
            "↑/↓ 滚动  PgUp/PgDn 翻页  Esc 关闭  L 语言",
            "↑/↓ scroll  PgUp/PgDn page  Esc close  L language",
        ),
        Overlay::SessionTranscript => crate::tui::i18n::pick(
            ui.language,
            "↑/↓ 滚动  PgUp/PgDn 翻页  g/G 顶/底  A 全量/尾部  y 复制  t/Esc 关闭  L 语言",
            "↑/↓ scroll  PgUp/PgDn page  g/G top/bottom  A all/tail  y copy  t/Esc close  L language",
        ),
    };
    let right = ui.toast.as_ref().map(|(s, _)| s.as_str()).unwrap_or("");

    let line = Line::from(vec![
        Span::styled(left, Style::default().fg(p.muted)),
        Span::raw(" "),
        Span::styled(right, Style::default().fg(p.accent)),
    ]);

    let block = Block::default()
        .borders(Borders::TOP)
        .border_style(Style::default().fg(p.border));
    f.render_widget(block, area);
    f.render_widget(
        Paragraph::new(Text::from(line)).wrap(Wrap { trim: true }),
        area,
    );
}