tact 0.1.1

Terminal interface for Nanocodex
//! Read-only context telemetry overlay.

use super::{
    floating::Floating,
    node::{Component, ComponentUpdate, RenderRequest},
};
use crate::tui::{
    context::{CompactionDiagnostics, CompactionTrigger, ContextDiagnostics, ContinuationMode},
    theme::Theme,
};
use chrono::{DateTime, Utc};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Paragraph, Wrap},
};

const FOOTER: [&str; 2] = ["r refresh", "esc close"];

pub(super) enum ContextDiagnosticsEvent {
    Terminal(Event),
}

#[derive(Debug, Eq, PartialEq)]
pub(super) enum ContextDiagnosticsEffect {
    Dismiss,
    Refresh,
}

pub(super) struct ContextDiagnosticsPanel {
    diagnostics: ContextDiagnostics,
}

impl ContextDiagnosticsPanel {
    pub(super) const fn new(diagnostics: ContextDiagnostics) -> Self {
        Self { diagnostics }
    }

    pub(super) fn replace(&mut self, diagnostics: ContextDiagnostics) {
        self.diagnostics = diagnostics;
    }

    fn lines(&self, theme: &Theme) -> Vec<Line<'static>> {
        let label = Style::default().fg(theme.muted());
        let value = Style::default().fg(theme.text());
        let heading = Style::default()
            .fg(theme.accent())
            .add_modifier(Modifier::BOLD);
        let mut lines = vec![Line::styled(" Context budget", heading)];
        let usage = self.diagnostics.usage;
        let current = usage.map(|usage| usage.total);
        lines.extend([
            fact(
                " Window / auto compact",
                format!(
                    "{} / {}",
                    format_count(self.diagnostics.model_window_tokens),
                    format_count(self.diagnostics.auto_compact_token_limit)
                ),
                label,
                value,
            ),
            fact(
                " Current / headroom",
                current.map_or_else(
                    || "unavailable".to_owned(),
                    |tokens| {
                        format!(
                            "{} / {}",
                            format_count(tokens),
                            format_count(
                                self.diagnostics.model_window_tokens.saturating_sub(tokens)
                            )
                        )
                    },
                ),
                label,
                value,
            ),
            fact(
                " Until auto compact",
                optional_count(current.map(|tokens| {
                    self.diagnostics
                        .auto_compact_token_limit
                        .saturating_sub(tokens)
                })),
                label,
                value,
            ),
            Line::styled(" Latest server usage", heading),
            fact(
                " Input (cached/uncached)",
                usage.map_or_else(
                    || "unavailable".to_owned(),
                    |usage| {
                        format!(
                            "{} ({}/{})",
                            format_count(usage.input),
                            format_count(usage.cached_input),
                            format_count(usage.uncached_input)
                        )
                    },
                ),
                label,
                value,
            ),
            fact(
                " Output / total",
                usage.map_or_else(
                    || "unavailable".to_owned(),
                    |usage| {
                        format!(
                            "{} / {}",
                            format_count(usage.output),
                            format_count(usage.total)
                        )
                    },
                ),
                label,
                value,
            ),
            fact(
                " Cached-token effect",
                "cached input still counts toward the window".to_owned(),
                label,
                value,
            ),
            Line::styled(" Generation", heading),
            fact(
                " Continuation",
                match self.diagnostics.continuation {
                    Some(ContinuationMode::FullContext) => "full context".to_owned(),
                    Some(ContinuationMode::PreviousResponse) => "previous response".to_owned(),
                    None => "unavailable".to_owned(),
                },
                label,
                value,
            ),
            fact(
                " Prompt cache",
                optional_bool(self.diagnostics.prompt_cache),
                label,
                value,
            ),
            Line::styled(" Local estimates", heading),
            fact(
                " Context categories",
                "unavailable (prefix/user/assistant/reasoning/tools/compaction/media)".to_owned(),
                label,
                value,
            ),
            fact(
                " Pending shell",
                "unavailable (count/bytes/tokens)".to_owned(),
                label,
                value,
            ),
            fact(
                " Server/local delta",
                "unavailable".to_owned(),
                label,
                value,
            ),
            Line::styled(" Compaction", heading),
            fact(
                " Started / completed",
                format!(
                    "{} / {}",
                    self.diagnostics.compactions_started, self.diagnostics.compactions_completed
                ),
                label,
                value,
            ),
        ]);
        let compaction = self.diagnostics.last_compaction;
        lines.extend([
            fact(
                " Trigger / time",
                compaction.map_or_else(|| "unavailable".to_owned(), format_compaction_time),
                label,
                value,
            ),
            fact(
                " Before / next input",
                compaction.map_or_else(
                    || "unavailable".to_owned(),
                    |item| {
                        format!(
                            "{} / {}",
                            optional_count(item.before_tokens),
                            optional_count(item.after_tokens)
                        )
                    },
                ),
                label,
                value,
            ),
        ]);
        lines
    }
}

impl Component for ContextDiagnosticsPanel {
    type Event = ContextDiagnosticsEvent;
    type Effect = ContextDiagnosticsEffect;

    fn update(&mut self, event: Self::Event) -> ComponentUpdate<Self::Effect> {
        let ContextDiagnosticsEvent::Terminal(Event::Key(key)) = event else {
            return ComponentUpdate::none();
        };
        if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
            return ComponentUpdate::none();
        }
        let effect = match key.code {
            KeyCode::Esc => ContextDiagnosticsEffect::Dismiss,
            KeyCode::Char('r') if key.modifiers == KeyModifiers::NONE => {
                ContextDiagnosticsEffect::Refresh
            }
            _ => return ComponentUpdate::none(),
        };
        ComponentUpdate {
            effects: vec![effect],
            render: RenderRequest::Immediate,
        }
    }

    fn render(&mut self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
        let layout =
            Floating::new("Context diagnostics", 78, 28, &FOOTER).render(frame, area, theme);
        if layout.body.is_empty() {
            return;
        }
        frame.render_widget(
            Paragraph::new(self.lines(theme)).wrap(Wrap { trim: false }),
            layout.body,
        );
    }
}

fn fact(
    label_text: &'static str,
    value_text: String,
    label_style: Style,
    value_style: Style,
) -> Line<'static> {
    Line::from(vec![
        Span::styled(format!("{label_text:<26}"), label_style),
        Span::styled(value_text, value_style),
    ])
}

fn optional_count(value: Option<u64>) -> String {
    value.map_or_else(|| "unavailable".to_owned(), format_count)
}

fn optional_bool(value: Option<bool>) -> String {
    value.map_or_else(
        || "unavailable".to_owned(),
        |present| if present { "present" } else { "absent" }.to_owned(),
    )
}

fn format_count(value: u64) -> String {
    let digits = value.to_string();
    let mut formatted = String::with_capacity(digits.len() + digits.len() / 3);
    for (index, character) in digits.chars().enumerate() {
        if index > 0 && (digits.len() - index).is_multiple_of(3) {
            formatted.push(',');
        }
        formatted.push(character);
    }
    formatted
}

fn format_compaction_time(compaction: CompactionDiagnostics) -> String {
    let trigger = match compaction.trigger {
        CompactionTrigger::Automatic => "automatic",
    };
    let timestamp = i64::try_from(compaction.started_at_unix_ms)
        .ok()
        .and_then(DateTime::<Utc>::from_timestamp_millis)
        .map_or_else(
            || "unknown time".to_owned(),
            |time| time.format("%Y-%m-%d %H:%M:%SZ").to_string(),
        );
    let duration = compaction.completed_at_unix_ms.map_or_else(
        || "ongoing".to_owned(),
        |completed| format_duration_millis(completed.saturating_sub(compaction.started_at_unix_ms)),
    );
    format!("{trigger} / {timestamp} · {duration}")
}

fn format_duration_millis(milliseconds: u64) -> String {
    if milliseconds < 1_000 {
        return format!("{milliseconds}ms");
    }
    format!("{}.{}s", milliseconds / 1_000, milliseconds % 1_000 / 100)
}

#[cfg(test)]
mod tests {
    use super::{Component, ContextDiagnosticsPanel, format_compaction_time};
    use crate::tui::{
        context::{
            CompactionDiagnostics, CompactionTrigger, ContextDiagnostics, ContinuationMode,
            TokenUsage,
        },
        theme::Theme,
    };
    use ratatui::{Terminal, backend::TestBackend};

    #[test]
    fn panel_renders_counts_unavailable_metrics_and_cache_help() {
        let mut diagnostics = ContextDiagnostics::default();
        diagnostics.usage = Some(TokenUsage {
            input: 100_000,
            cached_input: 75_000,
            uncached_input: 25_000,
            output: 2_000,
            total: 102_000,
        });
        diagnostics.continuation = Some(ContinuationMode::PreviousResponse);
        diagnostics.prompt_cache = Some(true);
        let mut panel = ContextDiagnosticsPanel::new(diagnostics);
        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
        terminal
            .draw(|frame| panel.render(frame, frame.area(), &Theme::default()))
            .unwrap();

        let rendered = terminal
            .backend()
            .buffer()
            .content()
            .chunks(80)
            .map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
            .collect::<Vec<_>>()
            .join("\n");
        for expected in [
            "Context diagnostics",
            "100,000 (75,000/25,000)",
            "previous response",
            "Context categories",
            "Pending shell",
            "count/bytes/tokens",
            "cached input still counts toward the window",
            "r refresh · esc close",
        ] {
            assert!(
                rendered.contains(expected),
                "missing {expected:?}\n{rendered}"
            );
        }
    }

    #[test]
    fn compaction_time_is_readable_and_includes_duration() {
        let rendered = format_compaction_time(CompactionDiagnostics {
            trigger: CompactionTrigger::Automatic,
            started_at_unix_ms: 0,
            completed_at_unix_ms: Some(39_095),
            before_tokens: None,
            after_tokens: None,
        });

        assert_eq!(rendered, "automatic / 1970-01-01 00:00:00Z · 39.0s");
    }
}