codex-helper-tui 0.15.0

Terminal UI crate for codex-helper.
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Color, Line, Modifier, Span, Style, Text};
use ratatui::widgets::{Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, Wrap};

use crate::sessions::SessionSummarySource;
use crate::tui::i18n::{self, msg};
use crate::tui::model::{Palette, basename, short_sid, shorten, shorten_middle};
use crate::tui::state::{CodexHistoryExternalFocusOrigin, UiState};

pub(super) fn render_history_page(f: &mut Frame<'_>, p: Palette, ui: &mut UiState, area: Rect) {
    let lang = ui.language;
    let l = |text| i18n::label(lang, text);
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
        .split(area);

    let title = i18n::text(lang, msg::HISTORY_TITLE);
    let left_block = Block::default()
        .title(Span::styled(
            title,
            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
        ))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(p.border))
        .style(Style::default().bg(p.panel));

    let header = Row::new([
        l("Updated"),
        l("Session"),
        l("CWD"),
        l("Rounds"),
        l("First user message"),
    ])
    .style(Style::default().fg(p.muted))
    .height(1);

    ui.sync_codex_history_selection();

    let rows = ui
        .codex_history_sessions
        .iter()
        .take(300)
        .map(|s| {
            let updated = s
                .updated_at
                .as_deref()
                .or(s.created_at.as_deref())
                .map(|t| shorten_middle(t, 20))
                .unwrap_or_else(|| "-".to_string());
            let sid = short_sid(s.id.as_str(), 18);
            let cwd = s
                .cwd
                .as_deref()
                .map(|v| shorten(basename(v), 14))
                .unwrap_or_else(|| "-".to_string());
            let rounds = s.rounds.to_string();
            let msg = s
                .first_user_message
                .as_deref()
                .map(history_message_preview)
                .unwrap_or_else(|| "-".to_string());

            Row::new(vec![
                Cell::from(Span::styled(updated, Style::default().fg(p.muted))),
                Cell::from(sid),
                Cell::from(Span::styled(cwd, Style::default().fg(p.muted))),
                Cell::from(Span::styled(rounds, Style::default().fg(p.muted))),
                Cell::from(msg),
            ])
            .style(Style::default().fg(p.text))
        })
        .collect::<Vec<_>>();

    let table = Table::new(
        rows,
        [
            Constraint::Length(20),
            Constraint::Length(18),
            Constraint::Length(16),
            Constraint::Length(6),
            Constraint::Min(20),
        ],
    )
    .header(header)
    .block(left_block)
    .row_highlight_style(Style::default().bg(Color::Rgb(32, 39, 48)))
    .highlight_symbol("  ")
    .highlight_spacing(HighlightSpacing::Always);
    f.render_stateful_widget(table, columns[0], &mut ui.codex_history_table);

    let right_block = Block::default()
        .title(Span::styled(
            i18n::text(ui.language, msg::DETAILS_TITLE),
            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
        ))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(p.border))
        .style(Style::default().bg(p.panel));

    let mut lines = Vec::new();
    if ui.codex_history_loading && !ui.codex_history_sessions.is_empty() {
        lines.push(Line::from(Span::styled(
            i18n::text(ui.language, msg::HISTORY_REFRESHING),
            Style::default().fg(p.accent),
        )));
        lines.push(Line::from(""));
    }
    if let Some(err) = ui.codex_history_error.as_deref() {
        lines.push(Line::from(Span::styled(
            format!("{}: {err}", l("error")),
            Style::default().fg(p.bad),
        )));
        lines.push(Line::from(""));
    }

    if ui.codex_history_sessions.is_empty() {
        lines.push(Line::from(Span::styled(
            if ui.codex_history_loading {
                i18n::text(ui.language, msg::HISTORY_REFRESHING)
            } else {
                i18n::text(ui.language, msg::HISTORY_EMPTY)
            },
            Style::default().fg(p.muted),
        )));
    } else if let Some(s) = ui.codex_history_sessions.get(ui.selected_codex_history_idx) {
        if let Some(focus) = ui
            .codex_history_external_focus
            .as_ref()
            .filter(|focus| focus.summary.id == s.id)
        {
            lines.push(Line::from(vec![
                Span::styled(format!("{}: ", l("context")), Style::default().fg(p.muted)),
                Span::styled(
                    format!(
                        "{} {}",
                        l("focused from"),
                        history_focus_origin_label(focus.origin, lang)
                    ),
                    Style::default().fg(p.accent),
                ),
            ]));
        }
        lines.push(Line::from(vec![
            Span::styled(format!("{}: ", l("sid")), Style::default().fg(p.muted)),
            Span::styled(s.id.clone(), Style::default().fg(p.text)),
        ]));
        lines.push(Line::from(vec![
            Span::styled(format!("{}: ", l("cwd")), Style::default().fg(p.muted)),
            Span::styled(
                s.cwd
                    .as_deref()
                    .map(|v| shorten_middle(v, 80))
                    .unwrap_or_else(|| "-".to_string()),
                Style::default().fg(p.text),
            ),
        ]));
        lines.push(Line::from(vec![
            Span::styled(format!("{}: ", l("updated")), Style::default().fg(p.muted)),
            Span::styled(
                s.updated_at
                    .as_deref()
                    .or(s.created_at.as_deref())
                    .map(|v| shorten_middle(v, 80))
                    .unwrap_or_else(|| "-".to_string()),
                Style::default().fg(p.muted),
            ),
        ]));
        lines.push(Line::from(vec![
            Span::styled(format!("{}: ", l("turns")), Style::default().fg(p.muted)),
            Span::styled(
                format!(
                    "{}={} {}={} {}={}",
                    l("user"),
                    s.user_turns,
                    l("assistant"),
                    s.assistant_turns,
                    l("rounds"),
                    s.rounds
                ),
                Style::default().fg(p.muted),
            ),
        ]));
        if let Some(ts) = s.last_response_at.as_deref() {
            lines.push(Line::from(vec![
                Span::styled(
                    format!("{}: ", l("last_response")),
                    Style::default().fg(p.muted),
                ),
                Span::styled(shorten_middle(ts, 80), Style::default().fg(p.muted)),
            ]));
        }
        lines.push(Line::from(vec![
            Span::styled(format!("{}: ", l("source")), Style::default().fg(p.muted)),
            Span::styled(
                match s.source {
                    SessionSummarySource::LocalFile => l("local transcript"),
                    SessionSummarySource::ObservedOnly => l("observed bridge"),
                },
                Style::default().fg(if s.source == SessionSummarySource::LocalFile {
                    p.text
                } else {
                    p.warn
                }),
            ),
        ]));

        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            i18n::text(ui.language, msg::FIRST_USER_MESSAGE),
            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
        )));
        if let Some(msg) = s.first_user_message.as_deref() {
            for line in msg.lines() {
                lines.push(Line::from(Span::raw(format!("  {line}"))));
            }
        } else {
            lines.push(Line::from(Span::styled(
                i18n::text(ui.language, msg::BULLET_DASH),
                Style::default().fg(p.muted),
            )));
        }

        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            i18n::text(ui.language, msg::KEYS_LABEL),
            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
        )));
        lines.push(Line::from(i18n::text(ui.language, msg::HISTORY_KEYS)));
        if s.source == SessionSummarySource::ObservedOnly {
            lines.push(Line::from(i18n::text(
                ui.language,
                msg::HISTORY_EXTERNAL_NO_TRANSCRIPT,
            )));
        }
    }

    let content = Paragraph::new(Text::from(lines))
        .block(right_block)
        .style(Style::default().fg(p.text))
        .wrap(Wrap { trim: false });
    f.render_widget(content, columns[1]);
}

fn history_focus_origin_label(
    origin: CodexHistoryExternalFocusOrigin,
    lang: crate::tui::Language,
) -> &'static str {
    match origin {
        CodexHistoryExternalFocusOrigin::Sessions => i18n::label(lang, "Sessions"),
        CodexHistoryExternalFocusOrigin::Requests => i18n::label(lang, "Requests"),
        CodexHistoryExternalFocusOrigin::Recent => i18n::label(lang, "Recent"),
    }
}

fn history_message_preview(message: &str) -> String {
    let first_line = message
        .lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("-");
    shorten(first_line, 80)
}

#[cfg(test)]
mod tests {
    use super::history_message_preview;

    #[test]
    fn history_message_preview_preserves_opening_words() {
        let preview = history_message_preview(
            "请帮我检查这次路由策略为什么会切换到 chili,然后看看余额展示是否清楚",
        );

        assert!(preview.starts_with("请帮我检查"), "{preview}");
    }
}