Skip to main content

codex_helper_tui/tui/
model.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use ratatui::prelude::{Color, Style};
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7use crate::dashboard_core::WindowStats;
8pub(in crate::tui) use crate::dashboard_core::window_stats::compute_window_stats;
9use crate::state::{
10    ActiveRequest, ConfigHealth, FinishedRequest, HealthCheckStatus, LbConfigView, ProxyState,
11    SessionStats, UsageRollupView,
12};
13use crate::usage::UsageMetrics;
14
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub struct UpstreamSummary {
17    pub base_url: String,
18    pub provider_id: Option<String>,
19    pub auth: String,
20    pub tags: Vec<(String, String)>,
21    pub supported_models: Vec<String>,
22    pub model_mapping: Vec<(String, String)>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct ProviderOption {
27    pub name: String,
28    pub alias: Option<String>,
29    pub enabled: bool,
30    pub level: u8,
31    pub active: bool,
32    pub upstreams: Vec<UpstreamSummary>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(in crate::tui) struct SessionRow {
37    pub(in crate::tui) session_id: Option<String>,
38    pub(in crate::tui) cwd: Option<String>,
39    pub(in crate::tui) active_count: usize,
40    pub(in crate::tui) active_started_at_ms_min: Option<u64>,
41    pub(in crate::tui) active_last_method: Option<String>,
42    pub(in crate::tui) active_last_path: Option<String>,
43    pub(in crate::tui) last_status: Option<u16>,
44    pub(in crate::tui) last_duration_ms: Option<u64>,
45    pub(in crate::tui) last_ended_at_ms: Option<u64>,
46    pub(in crate::tui) last_model: Option<String>,
47    pub(in crate::tui) last_reasoning_effort: Option<String>,
48    pub(in crate::tui) last_provider_id: Option<String>,
49    pub(in crate::tui) last_config_name: Option<String>,
50    pub(in crate::tui) last_usage: Option<UsageMetrics>,
51    pub(in crate::tui) total_usage: Option<UsageMetrics>,
52    pub(in crate::tui) turns_total: Option<u64>,
53    pub(in crate::tui) turns_with_usage: Option<u64>,
54    pub(in crate::tui) override_effort: Option<String>,
55    pub(in crate::tui) override_config_name: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59pub(in crate::tui) struct Snapshot {
60    pub(in crate::tui) rows: Vec<SessionRow>,
61    pub(in crate::tui) recent: Vec<FinishedRequest>,
62    pub(in crate::tui) overrides: HashMap<String, String>,
63    pub(in crate::tui) config_overrides: HashMap<String, String>,
64    pub(in crate::tui) global_override: Option<String>,
65    pub(in crate::tui) config_meta_overrides: HashMap<String, (Option<bool>, Option<u8>)>,
66    pub(in crate::tui) usage_rollup: UsageRollupView,
67    pub(in crate::tui) config_health: HashMap<String, ConfigHealth>,
68    pub(in crate::tui) health_checks: HashMap<String, HealthCheckStatus>,
69    pub(in crate::tui) lb_view: HashMap<String, LbConfigView>,
70    pub(in crate::tui) stats_5m: WindowStats,
71    pub(in crate::tui) stats_1h: WindowStats,
72    pub(in crate::tui) refreshed_at: Instant,
73}
74
75#[derive(Debug, Clone, Copy)]
76pub(in crate::tui) struct Palette {
77    pub(in crate::tui) bg: Color,
78    pub(in crate::tui) panel: Color,
79    pub(in crate::tui) border: Color,
80    pub(in crate::tui) text: Color,
81    pub(in crate::tui) muted: Color,
82    pub(in crate::tui) accent: Color,
83    pub(in crate::tui) focus: Color,
84    pub(in crate::tui) good: Color,
85    pub(in crate::tui) warn: Color,
86    pub(in crate::tui) bad: Color,
87}
88
89impl Default for Palette {
90    fn default() -> Self {
91        Self {
92            bg: Color::Rgb(14, 17, 22),
93            panel: Color::Rgb(18, 22, 28),
94            border: Color::Rgb(54, 62, 74),
95            text: Color::Rgb(224, 228, 234),
96            muted: Color::Rgb(144, 154, 164),
97            accent: Color::Rgb(88, 166, 255),
98            focus: Color::Rgb(121, 192, 255),
99            good: Color::Rgb(63, 185, 80),
100            warn: Color::Rgb(210, 153, 34),
101            bad: Color::Rgb(248, 81, 73),
102        }
103    }
104}
105
106pub(in crate::tui) fn now_ms() -> u64 {
107    std::time::SystemTime::now()
108        .duration_since(std::time::UNIX_EPOCH)
109        .map(|d| d.as_millis() as u64)
110        .unwrap_or(0)
111}
112
113pub(in crate::tui) const CODEX_RECENT_WINDOWS: [(u64, &str); 6] = [
114    (30 * 60, "30m"),
115    (60 * 60, "1h"),
116    (3 * 60 * 60, "3h"),
117    (8 * 60 * 60, "8h"),
118    (12 * 60 * 60, "12h"),
119    (24 * 60 * 60, "24h"),
120];
121
122pub(in crate::tui) fn codex_recent_window_label(idx: usize) -> &'static str {
123    CODEX_RECENT_WINDOWS[idx.min(CODEX_RECENT_WINDOWS.len() - 1)].1
124}
125
126pub(in crate::tui) fn codex_recent_window_threshold_ms(now_ms: u64, idx: usize) -> u64 {
127    let secs = CODEX_RECENT_WINDOWS[idx.min(CODEX_RECENT_WINDOWS.len() - 1)].0;
128    now_ms.saturating_sub(secs.saturating_mul(1000))
129}
130
131pub(in crate::tui) fn basename(path: &str) -> &str {
132    let path = path.trim_end_matches(['/', '\\']);
133    let slash = path.rfind('/');
134    let backslash = path.rfind('\\');
135    let idx = match (slash, backslash) {
136        (Some(a), Some(b)) => Some(a.max(b)),
137        (Some(a), None) => Some(a),
138        (None, Some(b)) => Some(b),
139        (None, None) => None,
140    };
141    if let Some(i) = idx {
142        &path[i.saturating_add(1)..]
143    } else {
144        path
145    }
146}
147
148pub(in crate::tui) fn shorten(s: &str, max: usize) -> String {
149    shorten_head(s, max)
150}
151
152pub(in crate::tui) fn shorten_middle(s: &str, max: usize) -> String {
153    if display_width(s) <= max {
154        return s.to_string();
155    }
156    if max == 0 {
157        return String::new();
158    }
159    if max == 1 {
160        return "…".to_string();
161    }
162    let remaining = max.saturating_sub(1);
163    let head_w = remaining / 2;
164    let tail_w = remaining.saturating_sub(head_w);
165    let head = prefix_by_width(s, head_w);
166    let tail = suffix_by_width(s, tail_w);
167    format!("{head}…{tail}")
168}
169
170fn shorten_head(s: &str, max: usize) -> String {
171    if display_width(s) <= max {
172        return s.to_string();
173    }
174    if max == 0 {
175        return String::new();
176    }
177    if max == 1 {
178        return "…".to_string();
179    }
180    let head = prefix_by_width(s, max.saturating_sub(1));
181    format!("{head}…")
182}
183
184fn display_width(s: &str) -> usize {
185    UnicodeWidthStr::width(s)
186}
187
188fn prefix_by_width(s: &str, max_width: usize) -> &str {
189    if max_width == 0 {
190        return "";
191    }
192    let mut width = 0usize;
193    let mut end = 0usize;
194    for (i, ch) in s.char_indices() {
195        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
196        if width.saturating_add(w) > max_width {
197            break;
198        }
199        width = width.saturating_add(w);
200        end = i.saturating_add(ch.len_utf8());
201    }
202    &s[..end]
203}
204
205fn suffix_by_width(s: &str, max_width: usize) -> &str {
206    if max_width == 0 {
207        return "";
208    }
209    let mut width = 0usize;
210    let mut start = s.len();
211    for (i, ch) in s.char_indices().rev() {
212        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
213        if width.saturating_add(w) > max_width {
214            break;
215        }
216        width = width.saturating_add(w);
217        start = i;
218    }
219    &s[start..]
220}
221
222pub(in crate::tui) fn short_sid(sid: &str, max: usize) -> String {
223    // Prefer head truncation (end ellipsis) over middle truncation so the string stays readable
224    // and copy/paste friendly in terminals.
225    shorten_head(sid, max)
226}
227
228pub fn build_provider_options(
229    cfg: &crate::config::ProxyConfig,
230    service_name: &str,
231) -> Vec<ProviderOption> {
232    let upstream_summary = |u: &crate::config::UpstreamConfig| -> UpstreamSummary {
233        let auth = if let Some(env) = u.auth.auth_token_env.as_deref()
234            && !env.trim().is_empty()
235        {
236            format!("bearer env {env}")
237        } else if u
238            .auth
239            .auth_token
240            .as_deref()
241            .is_some_and(|s| !s.trim().is_empty())
242        {
243            "bearer inline".to_string()
244        } else if let Some(env) = u.auth.api_key_env.as_deref()
245            && !env.trim().is_empty()
246        {
247            format!("x-api-key env {env}")
248        } else if u
249            .auth
250            .api_key
251            .as_deref()
252            .is_some_and(|s| !s.trim().is_empty())
253        {
254            "x-api-key inline".to_string()
255        } else {
256            "-".to_string()
257        };
258
259        let mut tags = u
260            .tags
261            .iter()
262            .map(|(k, v)| (k.clone(), v.clone()))
263            .collect::<Vec<_>>();
264        tags.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
265
266        let mut supported_models = u.supported_models.keys().cloned().collect::<Vec<_>>();
267        supported_models.sort();
268
269        let mut model_mapping = u
270            .model_mapping
271            .iter()
272            .map(|(k, v)| (k.clone(), v.clone()))
273            .collect::<Vec<_>>();
274        model_mapping.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
275
276        UpstreamSummary {
277            base_url: u.base_url.clone(),
278            provider_id: u.tags.get("provider_id").cloned(),
279            auth,
280            tags,
281            supported_models,
282            model_mapping,
283        }
284    };
285
286    let mut providers: Vec<ProviderOption> = match service_name {
287        "claude" => cfg
288            .claude
289            .configs
290            .iter()
291            .map(|(name, svc)| ProviderOption {
292                name: name.clone(),
293                alias: svc.alias.clone(),
294                enabled: svc.enabled,
295                level: svc.level.clamp(1, 10),
296                active: cfg.claude.active.as_deref() == Some(name.as_str()),
297                upstreams: svc.upstreams.iter().map(upstream_summary).collect(),
298            })
299            .collect(),
300        _ => cfg
301            .codex
302            .configs
303            .iter()
304            .map(|(name, svc)| ProviderOption {
305                name: name.clone(),
306                alias: svc.alias.clone(),
307                enabled: svc.enabled,
308                level: svc.level.clamp(1, 10),
309                active: cfg.codex.active.as_deref() == Some(name.as_str()),
310                upstreams: svc.upstreams.iter().map(upstream_summary).collect(),
311            })
312            .collect(),
313    };
314    providers.sort_by(|a, b| a.level.cmp(&b.level).then_with(|| a.name.cmp(&b.name)));
315    providers
316}
317
318fn session_sort_key(row: &SessionRow) -> u64 {
319    row.last_ended_at_ms
320        .unwrap_or(0)
321        .max(row.active_started_at_ms_min.unwrap_or(0))
322}
323
324pub(in crate::tui) fn format_age(now_ms: u64, ts_ms: Option<u64>) -> String {
325    let Some(ts) = ts_ms else {
326        return "-".to_string();
327    };
328    if now_ms <= ts {
329        return "0s".to_string();
330    }
331    let mut secs = (now_ms - ts) / 1000;
332    let days = secs / 86400;
333    secs %= 86400;
334    let hours = secs / 3600;
335    secs %= 3600;
336    let mins = secs / 60;
337    secs %= 60;
338    if days > 0 {
339        format!("{days}d{hours}h")
340    } else if hours > 0 {
341        format!("{hours}h{mins}m")
342    } else if mins > 0 {
343        format!("{mins}m{secs}s")
344    } else {
345        format!("{secs}s")
346    }
347}
348
349pub(in crate::tui) fn tokens_short(n: i64) -> String {
350    let n = n.max(0) as f64;
351    if n >= 1_000_000.0 {
352        format!("{:.1}m", n / 1_000_000.0)
353    } else if n >= 1_000.0 {
354        format!("{:.1}k", n / 1_000.0)
355    } else {
356        format!("{:.0}", n)
357    }
358}
359
360pub(in crate::tui) fn usage_line(usage: &UsageMetrics) -> String {
361    format!(
362        "tok in/out/rsn/ttl: {}/{}/{}/{}",
363        tokens_short(usage.input_tokens),
364        tokens_short(usage.output_tokens),
365        tokens_short(usage.reasoning_tokens),
366        tokens_short(usage.total_tokens)
367    )
368}
369
370pub(in crate::tui) fn status_style(p: Palette, status: Option<u16>) -> Style {
371    match status {
372        Some(s) if (200..300).contains(&s) => Style::default().fg(p.good),
373        Some(s) if (300..400).contains(&s) => Style::default().fg(p.accent),
374        Some(s) if (400..500).contains(&s) => Style::default().fg(p.warn),
375        Some(_) => Style::default().fg(p.bad),
376        None => Style::default().fg(p.muted),
377    }
378}
379
380fn build_session_rows(
381    active: Vec<ActiveRequest>,
382    recent: &[FinishedRequest],
383    overrides: &HashMap<String, String>,
384    config_overrides: &HashMap<String, String>,
385    stats: &HashMap<String, SessionStats>,
386) -> Vec<SessionRow> {
387    use std::collections::HashMap as StdHashMap;
388
389    let mut map: StdHashMap<Option<String>, SessionRow> = StdHashMap::new();
390
391    for req in active {
392        let key = req.session_id.clone();
393        let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
394            session_id: key,
395            cwd: req.cwd.clone(),
396            active_count: 0,
397            active_started_at_ms_min: Some(req.started_at_ms),
398            active_last_method: Some(req.method.clone()),
399            active_last_path: Some(req.path.clone()),
400            last_status: None,
401            last_duration_ms: None,
402            last_ended_at_ms: None,
403            last_model: req.model.clone(),
404            last_reasoning_effort: req.reasoning_effort.clone(),
405            last_provider_id: req.provider_id.clone(),
406            last_config_name: req.config_name.clone(),
407            last_usage: None,
408            total_usage: None,
409            turns_total: None,
410            turns_with_usage: None,
411            override_effort: None,
412            override_config_name: None,
413        });
414
415        entry.active_count += 1;
416        entry.active_started_at_ms_min = Some(
417            entry
418                .active_started_at_ms_min
419                .unwrap_or(req.started_at_ms)
420                .min(req.started_at_ms),
421        );
422        entry.active_last_method = Some(req.method);
423        entry.active_last_path = Some(req.path);
424        if entry.cwd.is_none() {
425            entry.cwd = req.cwd;
426        }
427        if let Some(effort) = req.reasoning_effort {
428            entry.last_reasoning_effort = Some(effort);
429        }
430        if entry.last_model.is_none() {
431            entry.last_model = req.model;
432        }
433        if entry.last_provider_id.is_none() {
434            entry.last_provider_id = req.provider_id;
435        }
436        if entry.last_config_name.is_none() {
437            entry.last_config_name = req.config_name;
438        }
439    }
440
441    for r in recent {
442        let key = r.session_id.clone();
443        let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
444            session_id: key,
445            cwd: r.cwd.clone(),
446            active_count: 0,
447            active_started_at_ms_min: None,
448            active_last_method: None,
449            active_last_path: None,
450            last_status: None,
451            last_duration_ms: None,
452            last_ended_at_ms: None,
453            last_model: r.model.clone(),
454            last_reasoning_effort: r.reasoning_effort.clone(),
455            last_provider_id: r.provider_id.clone(),
456            last_config_name: r.config_name.clone(),
457            last_usage: r.usage.clone(),
458            total_usage: None,
459            turns_total: None,
460            turns_with_usage: None,
461            override_effort: None,
462            override_config_name: None,
463        });
464
465        let should_update = entry
466            .last_ended_at_ms
467            .map(|t| r.ended_at_ms >= t)
468            .unwrap_or(true);
469        if should_update {
470            entry.last_status = Some(r.status_code);
471            entry.last_duration_ms = Some(r.duration_ms);
472            entry.last_ended_at_ms = Some(r.ended_at_ms);
473            if r.reasoning_effort.is_some() {
474                entry.last_reasoning_effort = r.reasoning_effort.clone();
475            }
476            if r.model.is_some() {
477                entry.last_model = r.model.clone();
478            }
479            if r.provider_id.is_some() {
480                entry.last_provider_id = r.provider_id.clone();
481            }
482            if r.config_name.is_some() {
483                entry.last_config_name = r.config_name.clone();
484            }
485            if r.usage.is_some() {
486                entry.last_usage = r.usage.clone();
487            }
488        }
489        if entry.cwd.is_none() {
490            entry.cwd = r.cwd.clone();
491        }
492    }
493
494    for (sid, st) in stats.iter() {
495        let key = Some(sid.clone());
496        let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
497            session_id: key,
498            cwd: None,
499            active_count: 0,
500            active_started_at_ms_min: None,
501            active_last_method: None,
502            active_last_path: None,
503            last_status: None,
504            last_duration_ms: None,
505            last_ended_at_ms: None,
506            last_model: st.last_model.clone(),
507            last_reasoning_effort: st.last_reasoning_effort.clone(),
508            last_provider_id: st.last_provider_id.clone(),
509            last_config_name: st.last_config_name.clone(),
510            last_usage: st.last_usage.clone(),
511            total_usage: Some(st.total_usage.clone()),
512            turns_total: None,
513            turns_with_usage: Some(st.turns_with_usage),
514            override_effort: None,
515            override_config_name: None,
516        });
517        entry.turns_total = Some(st.turns_total);
518        if entry.last_model.is_none() {
519            entry.last_model = st.last_model.clone();
520        }
521        if entry.last_reasoning_effort.is_none() {
522            entry.last_reasoning_effort = st.last_reasoning_effort.clone();
523        }
524        if entry.last_provider_id.is_none() {
525            entry.last_provider_id = st.last_provider_id.clone();
526        }
527        if entry.last_config_name.is_none() {
528            entry.last_config_name = st.last_config_name.clone();
529        }
530        if entry.last_usage.is_none() {
531            entry.last_usage = st.last_usage.clone();
532        }
533        if entry.total_usage.is_none() {
534            entry.total_usage = Some(st.total_usage.clone());
535        }
536        if entry.turns_with_usage.is_none() {
537            entry.turns_with_usage = Some(st.turns_with_usage);
538        }
539    }
540
541    for (sid, eff) in overrides.iter() {
542        let key = Some(sid.clone());
543        let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
544            session_id: key,
545            cwd: None,
546            active_count: 0,
547            active_started_at_ms_min: None,
548            active_last_method: None,
549            active_last_path: None,
550            last_status: None,
551            last_duration_ms: None,
552            last_ended_at_ms: None,
553            last_model: None,
554            last_reasoning_effort: None,
555            last_provider_id: None,
556            last_config_name: None,
557            last_usage: None,
558            total_usage: None,
559            turns_total: None,
560            turns_with_usage: None,
561            override_effort: None,
562            override_config_name: None,
563        });
564        entry.override_effort = Some(eff.clone());
565    }
566
567    for (sid, cfg_name) in config_overrides.iter() {
568        let key = Some(sid.clone());
569        let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
570            session_id: key,
571            cwd: None,
572            active_count: 0,
573            active_started_at_ms_min: None,
574            active_last_method: None,
575            active_last_path: None,
576            last_status: None,
577            last_duration_ms: None,
578            last_ended_at_ms: None,
579            last_model: None,
580            last_reasoning_effort: None,
581            last_provider_id: None,
582            last_config_name: None,
583            last_usage: None,
584            total_usage: None,
585            turns_total: None,
586            turns_with_usage: None,
587            override_effort: None,
588            override_config_name: None,
589        });
590        entry.override_config_name = Some(cfg_name.clone());
591    }
592
593    let mut rows = map.into_values().collect::<Vec<_>>();
594    rows.sort_by_key(|r| std::cmp::Reverse(session_sort_key(r)));
595    rows
596}
597
598pub(in crate::tui) async fn refresh_snapshot(
599    state: &ProxyState,
600    service_name: &str,
601    stats_days: usize,
602) -> Snapshot {
603    let (snap, config_meta) = tokio::join!(
604        crate::dashboard_core::build_dashboard_snapshot(state, service_name, 2_000, stats_days),
605        state.get_config_meta_overrides(service_name),
606    );
607
608    let rows = build_session_rows(
609        snap.active.clone(),
610        &snap.recent,
611        &snap.session_effort_overrides,
612        &snap.session_config_overrides,
613        &snap.session_stats,
614    );
615    Snapshot {
616        rows,
617        recent: snap.recent,
618        overrides: snap.session_effort_overrides,
619        config_overrides: snap.session_config_overrides,
620        global_override: snap.global_override,
621        config_meta_overrides: config_meta,
622        usage_rollup: snap.usage_rollup,
623        config_health: snap.config_health,
624        health_checks: snap.health_checks,
625        lb_view: snap.lb_view,
626        stats_5m: snap.stats_5m,
627        stats_1h: snap.stats_1h,
628        refreshed_at: Instant::now(),
629    }
630}
631
632pub(in crate::tui) fn filtered_requests_len(
633    snapshot: &Snapshot,
634    selected_session_idx: usize,
635) -> usize {
636    let selected_sid = snapshot
637        .rows
638        .get(selected_session_idx)
639        .and_then(|r| r.session_id.as_deref());
640    snapshot
641        .recent
642        .iter()
643        .filter(|r| match (selected_sid, r.session_id.as_deref()) {
644            (Some(sid), Some(rid)) => sid == rid,
645            (Some(_), None) => false,
646            (None, _) => true,
647        })
648        .take(60)
649        .count()
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    use unicode_width::UnicodeWidthStr;
657
658    #[test]
659    fn basename_handles_unix_and_windows_paths() {
660        assert_eq!(basename("/a/b/c"), "c");
661        assert_eq!(basename("/a/b/c/"), "c");
662        assert_eq!(basename(r"C:\a\b\c"), "c");
663        assert_eq!(basename(r"C:\a\b\c\"), "c");
664    }
665
666    #[test]
667    fn shorten_respects_display_width_cjk() {
668        let s = "你好世界";
669        let out = shorten(s, 5);
670        assert_eq!(out, "你好…");
671        assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
672    }
673
674    #[test]
675    fn shorten_middle_keeps_both_ends() {
676        let s = "abcdef";
677        assert_eq!(shorten_middle(s, 5), "ab…ef");
678    }
679}