Skip to main content

projectatlas/
token_tui.rs

1//! Purpose: Render token telemetry as package-backed terminal dashboards.
2
3use projectatlas_core::graph::{GraphRelationKind, LogicalRelation};
4use projectatlas_core::symbols::RelationKind;
5use projectatlas_core::telemetry::{
6    TOKEN_ACCOUNTING_OBSERVED_DELTA, TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_FULL_FILE,
7    TOKEN_BASELINE_SELECTED_CANDIDATES, TOKEN_BUCKET_FULL_FILE_COMPRESSION, TokenBucketOverview,
8    TokenOverview, TokenTrendPeriod, TokenTrendReport,
9};
10use ratatui::backend::TestBackend;
11use ratatui::buffer::{Buffer, CellWidth};
12use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::symbols;
15use ratatui::text::{Line, Span};
16use ratatui::widgets::canvas::{Canvas, Circle, Line as CanvasLine, Points};
17use ratatui::widgets::{Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, Row, Table, Wrap};
18use ratatui::{Frame, Terminal};
19use std::cell::Cell as StdCell;
20use std::collections::{BTreeMap, BTreeSet, VecDeque};
21use std::io::{self, IsTerminal};
22use std::num::NonZeroU16;
23use std::time::{SystemTime, UNIX_EPOCH};
24
25/// Fixed terminal height for the token overview dashboard snapshot.
26const DASHBOARD_HEIGHT: u16 = 50;
27/// Minimum terminal width for the full token dashboards.
28const DASHBOARD_MIN_WIDTH: u16 = 80;
29/// Width at which the human dashboard can show the atlas without crowding impact data.
30const ATLAS_DASHBOARD_MIN_WIDTH: u16 = 190;
31/// Maximum human dashboard width.
32const DASHBOARD_MAX_WIDTH: u16 = 200;
33/// Default non-terminal dashboard width.
34const DASHBOARD_DEFAULT_WIDTH: u16 = 140;
35/// Stable width reserved for the token-impact column in the wide dashboard.
36const TOKEN_IMPACT_COLUMN_WIDTH: u16 = 140;
37/// Maximum real resolved nodes retained by the decorative atlas preview.
38const ATLAS_PREVIEW_MAX_NODES: usize = 48;
39/// Maximum real resolved links retained by the decorative atlas preview.
40const ATLAS_PREVIEW_MAX_EDGES: usize = 64;
41/// Maximum links one visual hub may consume in the decorative preview.
42const ATLAS_PREVIEW_MAX_NODE_DEGREE: usize = 12;
43/// Horizontal Canvas bound retaining a margin inside the atlas panel.
44const ATLAS_CANVAS_X_BOUND: f64 = 33.0;
45/// Vertical Canvas bound retaining a margin above the atlas footer.
46const ATLAS_CANVAS_Y_BOUND: f64 = 21.0;
47/// Fixed force steps keep the bounded static preview deterministic and fast.
48const ATLAS_LAYOUT_ITERATIONS: usize = 120;
49/// Ideal graph-space edge length for the bounded force layout.
50const ATLAS_LAYOUT_IDEAL_DISTANCE: f64 = 18.0;
51/// Maximum per-step node movement before deterministic cooling.
52const ATLAS_LAYOUT_INITIAL_TEMPERATURE: f64 = 8.0;
53/// Degree at which a node receives a small depth halo instead of a single point.
54const ATLAS_NODE_HALO_DEGREE: usize = 4;
55/// Fixed terminal height for the token trend dashboard snapshot.
56const TREND_DASHBOARD_HEIGHT: u16 = 30;
57/// Maximum human trend dashboard width.
58const TREND_DASHBOARD_MAX_WIDTH: u16 = 140;
59/// Reserved terminal-canvas color; overview frames leave the shell background visible.
60const THEME_BG: Color = Color::Rgb(4, 10, 18);
61/// Token dashboard panel background.
62const THEME_PANEL: Color = Color::Rgb(5, 16, 25);
63/// Token dashboard primary warm text.
64const THEME_TEXT: Color = Color::Rgb(224, 198, 164);
65/// Token dashboard muted label text.
66const THEME_MUTED: Color = Color::Rgb(170, 143, 116);
67/// Token dashboard identity ivory.
68const THEME_INK_WHITE: Color = Color::Rgb(238, 234, 224);
69/// Counterfactual/original-baseline blue.
70const THEME_BLUE: Color = Color::Rgb(93, 143, 255);
71/// Net saved/success green.
72const THEME_GREEN: Color = Color::Rgb(111, 216, 100);
73/// Modeled/search/estimate yellow.
74const THEME_YELLOW: Color = Color::Rgb(230, 179, 55);
75/// Token dashboard subtle warm panel border.
76const THEME_BORDER: Color = Color::Rgb(92, 74, 55);
77/// Token dashboard inactive bar cells.
78const THEME_BAR_EMPTY: Color = Color::Rgb(49, 56, 57);
79/// Token dashboard loss red.
80const THEME_RED: Color = Color::Rgb(235, 95, 95);
81/// Repository-graph test and route accent.
82const THEME_PURPLE: Color = Color::Rgb(173, 127, 255);
83/// Human token dashboard color mode.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub(crate) enum TokenDashboardTheme {
86    /// Reference dark dashboard theme.
87    Dark,
88    /// Light dashboard theme for light terminal backgrounds.
89    Light,
90    /// Preserve the terminal background and foreground while retaining semantic accents.
91    Terminal,
92}
93
94/// One validated terminal viewport shared by loading, layout, and serialization.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub(crate) struct TokenDashboardViewport {
97    /// Available terminal columns.
98    columns: NonZeroU16,
99    /// Available terminal rows.
100    rows: NonZeroU16,
101}
102
103impl TokenDashboardViewport {
104    /// Return the selected terminal columns.
105    const fn columns(self) -> u16 {
106        self.columns.get()
107    }
108
109    /// Return the selected terminal rows.
110    const fn rows(self) -> u16 {
111        self.rows.get()
112    }
113
114    /// Return whether the full overview fits this viewport.
115    const fn fits_overview(self) -> bool {
116        self.columns() >= DASHBOARD_MIN_WIDTH && self.rows() >= DASHBOARD_HEIGHT
117    }
118
119    /// Return whether the full trend view fits this viewport.
120    const fn fits_trend(self) -> bool {
121        self.columns() >= DASHBOARD_MIN_WIDTH && self.rows() >= TREND_DASHBOARD_HEIGHT
122    }
123
124    /// Return the bounded overview render width.
125    fn overview_width(self) -> u16 {
126        self.columns().min(DASHBOARD_MAX_WIDTH)
127    }
128
129    /// Return the bounded trend render width.
130    fn trend_width(self) -> u16 {
131        self.columns().min(TREND_DASHBOARD_MAX_WIDTH)
132    }
133}
134
135impl TokenDashboardTheme {
136    /// Parse a token dashboard theme value.
137    pub(crate) fn parse(value: &str) -> Option<Self> {
138        match value {
139            "dark" => Some(Self::Dark),
140            "light" => Some(Self::Light),
141            "terminal" => Some(Self::Terminal),
142            _ => None,
143        }
144    }
145}
146
147/// Semantic color palette used when serializing Ratatui cells to ANSI.
148#[derive(Clone, Copy)]
149struct ThemePalette {
150    /// Full-screen background.
151    bg: Color,
152    /// Panel background.
153    panel: Color,
154    /// Primary text.
155    text: Color,
156    /// Muted text.
157    muted: Color,
158    /// Product identity color.
159    ink_white: Color,
160    /// Counterfactual baseline blue.
161    blue: Color,
162    /// Saved/success green.
163    green: Color,
164    /// Modeled/estimate yellow.
165    yellow: Color,
166    /// Panel border.
167    border: Color,
168    /// Empty bar fill.
169    bar_empty: Color,
170    /// Negative/loss red.
171    red: Color,
172    /// Repository graph accent.
173    purple: Color,
174}
175
176/// One real resolved relation retained by the bounded atlas preview.
177#[derive(Clone, Debug, Eq, PartialEq)]
178struct AtlasPreviewEdge {
179    /// Stable compact source identity.
180    source: String,
181    /// Stable compact target identity.
182    target: String,
183    /// Typed relation family used for semantic color.
184    kind: GraphRelationKind,
185}
186
187/// Bounded, non-interactive projection of resolved relations from the active project database.
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub(crate) struct TokenAtlasPreview {
190    /// Real resolved relations admitted within the node and edge ceilings.
191    edges: Vec<AtlasPreviewEdge>,
192    /// Whether a source page or a preview ceiling omitted additional relations.
193    truncated: bool,
194    /// Whether the optional graph read completed successfully.
195    available: bool,
196}
197
198/// Return whether a relation belongs in the cross-entity atlas network.
199pub(crate) const fn token_atlas_network_relation(kind: GraphRelationKind) -> bool {
200    !matches!(kind, GraphRelationKind::Legacy(RelationKind::Contains))
201}
202
203impl TokenAtlasPreview {
204    /// Build an empty but available graph snapshot.
205    #[must_use]
206    pub(crate) const fn empty() -> Self {
207        Self {
208            edges: Vec::new(),
209            truncated: false,
210            available: true,
211        }
212    }
213
214    /// Build the explicit state used when the optional graph read fails.
215    #[must_use]
216    pub(crate) const fn unavailable() -> Self {
217        Self {
218            edges: Vec::new(),
219            truncated: false,
220            available: false,
221        }
222    }
223
224    /// Retain only exact local resolutions from bounded database relation pages.
225    #[must_use]
226    pub(crate) fn from_relations(relations: &[LogicalRelation], source_truncated: bool) -> Self {
227        Self::from_resolved_edges(
228            relations.iter().filter_map(|relation| {
229                relation.resolution().resolved_target().map(|target| {
230                    (
231                        relation.source().digest().to_string(),
232                        target.digest().to_string(),
233                        relation.kind(),
234                    )
235                })
236            }),
237            source_truncated,
238        )
239    }
240
241    /// Build the bounded projection from already resolved stable identities.
242    fn from_resolved_edges(
243        relations: impl IntoIterator<Item = (String, String, GraphRelationKind)>,
244        source_truncated: bool,
245    ) -> Self {
246        let mut candidates = BTreeMap::new();
247        for (source, target, kind) in relations {
248            if source == target || !token_atlas_network_relation(kind) {
249                continue;
250            }
251            candidates
252                .entry((source.clone(), target.clone(), kind.as_str()))
253                .or_insert(AtlasPreviewEdge {
254                    source,
255                    target,
256                    kind,
257                });
258        }
259        let mut degrees = BTreeMap::<String, usize>::new();
260        let mut adjacency = BTreeMap::<String, BTreeSet<String>>::new();
261        for edge in candidates.values() {
262            *degrees.entry(edge.source.clone()).or_default() += 1;
263            *degrees.entry(edge.target.clone()).or_default() += 1;
264            adjacency
265                .entry(edge.source.clone())
266                .or_default()
267                .insert(edge.target.clone());
268            adjacency
269                .entry(edge.target.clone())
270                .or_default()
271                .insert(edge.source.clone());
272        }
273        let mut candidates = candidates.into_values().collect::<Vec<_>>();
274        candidates.sort_by(|left, right| {
275            let score = |edge: &AtlasPreviewEdge| {
276                degrees.get(&edge.source).copied().unwrap_or_default()
277                    + degrees.get(&edge.target).copied().unwrap_or_default()
278            };
279            score(right)
280                .cmp(&score(left))
281                .then_with(|| left.source.cmp(&right.source))
282                .then_with(|| left.target.cmp(&right.target))
283                .then_with(|| left.kind.as_str().cmp(right.kind.as_str()))
284        });
285
286        let mut remaining = adjacency.keys().cloned().collect::<BTreeSet<_>>();
287        let mut largest_component = BTreeSet::new();
288        while let Some(start) = remaining.first().cloned() {
289            remaining.remove(&start);
290            let mut component = BTreeSet::from([start.clone()]);
291            let mut frontier = VecDeque::from([start]);
292            while let Some(node) = frontier.pop_front() {
293                if let Some(neighbors) = adjacency.get(&node) {
294                    for neighbor in neighbors {
295                        if remaining.remove(neighbor) {
296                            component.insert(neighbor.clone());
297                            frontier.push_back(neighbor.clone());
298                        }
299                    }
300                }
301            }
302            let replace = component.len() > largest_component.len()
303                || (component.len() == largest_component.len()
304                    && component.first() < largest_component.first());
305            if replace {
306                largest_component = component;
307            }
308        }
309        let Some(hub) = largest_component
310            .iter()
311            .max_by(|left, right| {
312                degrees
313                    .get(*left)
314                    .cmp(&degrees.get(*right))
315                    .then_with(|| right.cmp(left))
316            })
317            .cloned()
318        else {
319            return Self {
320                edges: Vec::new(),
321                truncated: source_truncated,
322                available: true,
323            };
324        };
325        let omitted_disconnected = candidates.iter().any(|edge| {
326            !largest_component.contains(&edge.source) || !largest_component.contains(&edge.target)
327        });
328        candidates.retain(|edge| {
329            largest_component.contains(&edge.source) && largest_component.contains(&edge.target)
330        });
331        let mut branch_reach = BTreeMap::<String, BTreeMap<String, usize>>::new();
332        for edge in &candidates {
333            for (blocked, start) in [(&edge.source, &edge.target), (&edge.target, &edge.source)] {
334                let reach = atlas_branch_reach(&adjacency, blocked, start);
335                branch_reach
336                    .entry(blocked.clone())
337                    .or_default()
338                    .insert(start.clone(), reach);
339            }
340        }
341        let mut nodes = BTreeSet::from([hub]);
342        let mut edges = Vec::new();
343        let mut selected_degrees = BTreeMap::<String, usize>::new();
344        while edges.len() < ATLAS_PREVIEW_MAX_EDGES {
345            let can_admit = |edge: &AtlasPreviewEdge| {
346                selected_degrees
347                    .get(&edge.source)
348                    .copied()
349                    .unwrap_or_default()
350                    < ATLAS_PREVIEW_MAX_NODE_DEGREE
351                    && selected_degrees
352                        .get(&edge.target)
353                        .copied()
354                        .unwrap_or_default()
355                        < ATLAS_PREVIEW_MAX_NODE_DEGREE
356            };
357            let next_index = candidates
358                .iter()
359                .enumerate()
360                .filter_map(|(index, edge)| {
361                    let source_selected = nodes.contains(&edge.source);
362                    let target_selected = nodes.contains(&edge.target);
363                    if source_selected == target_selected
364                        || nodes.len() >= ATLAS_PREVIEW_MAX_NODES
365                        || !can_admit(edge)
366                    {
367                        return None;
368                    }
369                    let (selected, unselected) = if source_selected {
370                        (&edge.source, &edge.target)
371                    } else {
372                        (&edge.target, &edge.source)
373                    };
374                    let reach = branch_reach
375                        .get(selected)
376                        .and_then(|by_neighbor| by_neighbor.get(unselected))
377                        .copied()
378                        .unwrap_or_default();
379                    let expansion_degree = degrees.get(unselected).copied().unwrap_or_default();
380                    Some((index, reach, expansion_degree))
381                })
382                .max_by(|left, right| {
383                    left.1
384                        .cmp(&right.1)
385                        .then_with(|| left.2.cmp(&right.2))
386                        .then_with(|| right.0.cmp(&left.0))
387                })
388                .map(|(index, _, _)| index)
389                .or_else(|| {
390                    candidates.iter().position(|edge| {
391                        nodes.contains(&edge.source)
392                            && nodes.contains(&edge.target)
393                            && can_admit(edge)
394                    })
395                });
396            let Some(next_index) = next_index else {
397                break;
398            };
399            let edge = candidates.remove(next_index);
400            nodes.insert(edge.source.clone());
401            nodes.insert(edge.target.clone());
402            *selected_degrees.entry(edge.source.clone()).or_default() += 1;
403            *selected_degrees.entry(edge.target.clone()).or_default() += 1;
404            edges.push(edge);
405        }
406        Self {
407            edges,
408            truncated: source_truncated || omitted_disconnected || !candidates.is_empty(),
409            available: true,
410        }
411    }
412
413    /// Return the exact number of distinct nodes drawn by this preview.
414    fn node_count(&self) -> usize {
415        self.edges
416            .iter()
417            .flat_map(|edge| [&edge.source, &edge.target])
418            .collect::<BTreeSet<_>>()
419            .len()
420    }
421}
422
423/// Count one candidate branch without crossing back through its selected endpoint.
424fn atlas_branch_reach(
425    adjacency: &BTreeMap<String, BTreeSet<String>>,
426    blocked: &str,
427    start: &str,
428) -> usize {
429    let mut visited = BTreeSet::from([start.to_string()]);
430    let mut frontier = VecDeque::from([start.to_string()]);
431    while let Some(node) = frontier.pop_front() {
432        if let Some(neighbors) = adjacency.get(&node) {
433            for neighbor in neighbors {
434                if neighbor != blocked && visited.insert(neighbor.clone()) {
435                    frontier.push_back(neighbor.clone());
436                }
437            }
438        }
439    }
440    visited.len()
441}
442
443/// Light terminal palette preserving the same semantic color roles.
444const LIGHT_THEME: ThemePalette = ThemePalette {
445    bg: Color::Rgb(252, 249, 241),
446    panel: Color::Rgb(246, 242, 232),
447    text: Color::Rgb(34, 32, 28),
448    muted: Color::Rgb(96, 88, 76),
449    ink_white: Color::Rgb(22, 22, 20),
450    blue: Color::Rgb(37, 99, 235),
451    green: Color::Rgb(22, 128, 72),
452    yellow: Color::Rgb(178, 116, 0),
453    border: Color::Rgb(175, 151, 111),
454    bar_empty: Color::Rgb(218, 210, 196),
455    red: Color::Rgb(190, 52, 52),
456    purple: Color::Rgb(126, 70, 180),
457};
458
459thread_local! {
460    /// Active token dashboard theme for the current render pass.
461    static ACTIVE_TOKEN_THEME: StdCell<TokenDashboardTheme> = const { StdCell::new(TokenDashboardTheme::Dark) };
462}
463
464/// Render the token overview as a human terminal dashboard.
465#[cfg(test)]
466pub(crate) fn render_token_dashboard(overview: &TokenOverview, session: Option<&str>) -> String {
467    render_token_dashboard_with_theme(overview, session, TokenDashboardTheme::Dark)
468}
469
470/// Render the token overview as a human terminal dashboard with the selected theme.
471#[cfg(test)]
472pub(crate) fn render_token_dashboard_with_theme(
473    overview: &TokenOverview,
474    session: Option<&str>,
475    theme: TokenDashboardTheme,
476) -> String {
477    let rendered = with_token_theme(theme, || {
478        render_dashboard_to_ansi_string(DASHBOARD_DEFAULT_WIDTH, DASHBOARD_HEIGHT, |frame| {
479            render_overview_frame(frame, overview, session);
480        })
481    });
482    match rendered {
483        Ok(dashboard) => dashboard,
484        Err(error) => unreachable!("in-memory token dashboard render failed: {error}"),
485    }
486}
487
488/// Render the human token dashboard with its optional bounded live atlas.
489pub(crate) fn render_token_dashboard_with_atlas(
490    overview: &TokenOverview,
491    session: Option<&str>,
492    atlas: &TokenAtlasPreview,
493    theme: TokenDashboardTheme,
494    viewport: TokenDashboardViewport,
495) -> io::Result<String> {
496    with_token_theme(theme, || {
497        if viewport.fits_overview() {
498            render_dashboard_to_ansi_string(viewport.overview_width(), DASHBOARD_HEIGHT, |frame| {
499                render_overview_frame_with_atlas(frame, overview, session, Some(atlas));
500            })
501        } else {
502            render_compact_overview(overview, session, viewport)
503        }
504    })
505}
506
507/// Render one deterministic test dashboard with an explicit atlas width.
508#[cfg(test)]
509pub(crate) fn render_token_dashboard_with_atlas_at_width(
510    overview: &TokenOverview,
511    session: Option<&str>,
512    atlas: &TokenAtlasPreview,
513    width: u16,
514) -> String {
515    with_token_theme(TokenDashboardTheme::Dark, || {
516        render_dashboard_to_string(width, DASHBOARD_HEIGHT, |frame| {
517            render_overview_frame_with_atlas(frame, overview, session, Some(atlas));
518        })
519    })
520}
521
522/// Capture one validated viewport for token loading, rendering, and serialization.
523#[must_use]
524pub(crate) fn capture_token_dashboard_viewport() -> TokenDashboardViewport {
525    let terminal_size = if io::stdout().is_terminal() {
526        ratatui::crossterm::terminal::size().ok()
527    } else {
528        None
529    };
530    resolve_dashboard_viewport(
531        terminal_size,
532        dashboard_environment_dimension("COLUMNS"),
533        dashboard_environment_dimension("LINES"),
534    )
535}
536
537/// Return whether a captured viewport can show the optional atlas.
538#[must_use]
539pub(crate) fn token_dashboard_wants_atlas(viewport: TokenDashboardViewport) -> bool {
540    viewport.fits_overview() && viewport.overview_width() >= ATLAS_DASHBOARD_MIN_WIDTH
541}
542
543/// Render the token overview as a plain terminal chart for agent payloads.
544pub(crate) fn render_token_dashboard_plain_with_theme(
545    overview: &TokenOverview,
546    session: Option<&str>,
547    theme: TokenDashboardTheme,
548) -> String {
549    let width = dashboard_width().clamp(
550        usize::from(DASHBOARD_MIN_WIDTH),
551        usize::from(TREND_DASHBOARD_MAX_WIDTH),
552    ) as u16;
553    with_token_theme(theme, || {
554        render_dashboard_to_string(width, DASHBOARD_HEIGHT, |frame| {
555            render_overview_frame(frame, overview, session);
556        })
557    })
558}
559
560/// Render token trends as a human terminal dashboard.
561#[cfg(test)]
562pub(crate) fn render_token_trend_dashboard(report: &TokenTrendReport) -> String {
563    let rendered = render_token_trend_dashboard_with_theme_in_viewport(
564        report,
565        TokenDashboardTheme::Dark,
566        resolve_dashboard_viewport(None, None, None),
567    );
568    match rendered {
569        Ok(dashboard) => dashboard,
570        Err(error) => unreachable!("in-memory token trend dashboard render failed: {error}"),
571    }
572}
573
574/// Render token trends as a human terminal dashboard with the selected theme.
575pub(crate) fn render_token_trend_dashboard_with_theme(
576    report: &TokenTrendReport,
577    theme: TokenDashboardTheme,
578) -> io::Result<String> {
579    render_token_trend_dashboard_with_theme_in_viewport(
580        report,
581        theme,
582        capture_token_dashboard_viewport(),
583    )
584}
585
586/// Render token trends inside one previously captured viewport.
587pub(crate) fn render_token_trend_dashboard_with_theme_in_viewport(
588    report: &TokenTrendReport,
589    theme: TokenDashboardTheme,
590    viewport: TokenDashboardViewport,
591) -> io::Result<String> {
592    with_token_theme(theme, || {
593        if viewport.fits_trend() {
594            render_dashboard_to_ansi_string(
595                viewport.trend_width(),
596                TREND_DASHBOARD_HEIGHT,
597                |frame| {
598                    render_trend_frame(frame, report);
599                },
600            )
601        } else {
602            render_compact_trend(report, viewport)
603        }
604    })
605}
606
607/// Render token trends as a plain terminal chart for agent payloads.
608pub(crate) fn render_token_trend_dashboard_plain_with_theme(
609    report: &TokenTrendReport,
610    theme: TokenDashboardTheme,
611) -> String {
612    let width = dashboard_width().clamp(
613        usize::from(DASHBOARD_MIN_WIDTH),
614        usize::from(TREND_DASHBOARD_MAX_WIDTH),
615    ) as u16;
616    with_token_theme(theme, || {
617        render_dashboard_to_string(width, TREND_DASHBOARD_HEIGHT, |frame| {
618            render_trend_frame(frame, report);
619        })
620    })
621}
622
623/// Run one render closure with the selected token dashboard theme.
624fn with_token_theme<R>(theme: TokenDashboardTheme, render: impl FnOnce() -> R) -> R {
625    ACTIVE_TOKEN_THEME.with(|active| {
626        let previous = active.replace(theme);
627        let result = render();
628        active.set(previous);
629        result
630    })
631}
632
633/// Return the active token dashboard theme.
634fn active_token_theme() -> TokenDashboardTheme {
635    ACTIVE_TOKEN_THEME.with(StdCell::get)
636}
637
638/// Render one Ratatui frame into a deterministic ANSI terminal buffer.
639fn render_dashboard_to_ansi_string<F>(width: u16, height: u16, render: F) -> io::Result<String>
640where
641    F: FnOnce(&mut Frame<'_>),
642{
643    let backend = TestBackend::new(width, height);
644    let mut terminal = Terminal::new(backend).map_err(|error| -> io::Error { match error {} })?;
645    let frame = terminal
646        .draw(render)
647        .map_err(|error| -> io::Error { match error {} })?;
648    Ok(buffer_to_ansi_string(frame.buffer))
649}
650
651/// Render the priority-ordered compact overview inside the available viewport.
652fn render_compact_overview(
653    overview: &TokenOverview,
654    session: Option<&str>,
655    viewport: TokenDashboardViewport,
656) -> io::Result<String> {
657    let lines = compact_overview_lines(overview, session);
658    let height = viewport
659        .rows()
660        .min(u16::try_from(lines.len()).unwrap_or(u16::MAX));
661    render_dashboard_to_ansi_string(viewport.overview_width(), height, move |frame| {
662        frame.render_widget(Paragraph::new(lines), frame.area());
663    })
664}
665
666/// Return compact overview facts in descending display priority.
667fn compact_overview_lines<'a>(
668    overview: &'a TokenOverview,
669    session: Option<&'a str>,
670) -> Vec<Line<'a>> {
671    let average = overview.average_tokens_avoided;
672    let with_projectatlas = usize_to_isize_saturating(overview.estimated_with_projectatlas);
673    let without_projectatlas = reconciled_without_projectatlas(overview);
674    let mix = file_handling_token_mix(overview);
675    vec![
676        Line::from(vec![
677            Span::styled("ProjectAtlas", identity_title_style()),
678            Span::styled(
679                " Token Impact",
680                Style::default().fg(THEME_BLUE).add_modifier(Modifier::BOLD),
681            ),
682        ]),
683        Line::from(vec![
684            Span::styled("Average avoided: ", muted_bold_style()),
685            Span::styled(signed_count(average), signed_savings_style(average)),
686        ]),
687        Line::from(vec![
688            Span::styled("Without ", muted_style()),
689            Span::styled(signed_count(without_projectatlas), token_title_style()),
690            Span::raw(" - With "),
691            Span::styled(signed_count(with_projectatlas), identity_style()),
692            Span::raw(" = Avoided "),
693            Span::styled(signed_count(average), signed_savings_style(average)),
694        ]),
695        Line::from(vec![
696            Span::styled("File reads: ", muted_bold_style()),
697            Span::styled(
698                grouped_count(overview.observed_file_read_replacements),
699                identity_style(),
700            ),
701            Span::raw(" observed + "),
702            Span::styled(
703                grouped_count(overview.modeled_file_reads_avoided),
704                Style::default().fg(THEME_YELLOW),
705            ),
706            Span::raw(" modeled = "),
707            Span::styled(
708                grouped_count(overview.likely_file_reads_avoided),
709                identity_style(),
710            ),
711        ]),
712        Line::from(vec![
713            Span::styled("Token mix: ", muted_bold_style()),
714            Span::styled(signed_count(mix.observed), identity_style()),
715            Span::raw(" measured + "),
716            Span::styled(signed_count(mix.modeled), Style::default().fg(THEME_YELLOW)),
717            Span::raw(" modeled = "),
718            Span::styled(signed_count(mix.net()), signed_savings_style(mix.net())),
719        ]),
720        Line::from(vec![
721            Span::styled("Lookups: ", muted_bold_style()),
722            value(overview.calls),
723            Span::raw("   "),
724            Span::styled("Session: ", muted_bold_style()),
725            Span::styled(session.unwrap_or("all sessions"), body_style()),
726        ]),
727        Line::from(vec![
728            Span::styled("Estimate: ", muted_bold_style()),
729            Span::styled(overview.estimate_scope.as_str(), body_style()),
730            Span::raw("   "),
731            Span::styled("Confidence: ", muted_bold_style()),
732            Span::styled(
733                overview.read_avoidance_confidence.as_str(),
734                Style::default().fg(THEME_YELLOW),
735            ),
736        ]),
737        Line::from(Span::styled(
738            format!("ProjectAtlas v{}", env!("CARGO_PKG_VERSION")),
739            identity_style(),
740        )),
741    ]
742}
743
744/// Render the priority-ordered compact trend inside the available viewport.
745fn render_compact_trend(
746    report: &TokenTrendReport,
747    viewport: TokenDashboardViewport,
748) -> io::Result<String> {
749    let lines = compact_trend_lines(report);
750    let height = viewport
751        .rows()
752        .min(u16::try_from(lines.len()).unwrap_or(u16::MAX));
753    render_dashboard_to_ansi_string(viewport.trend_width(), height, move |frame| {
754        frame.render_widget(Paragraph::new(lines), frame.area());
755    })
756}
757
758/// Return compact trend facts in descending display priority.
759fn compact_trend_lines(report: &TokenTrendReport) -> Vec<Line<'_>> {
760    let mut lines = vec![Line::from(Span::styled(
761        "ProjectAtlas Token Trends",
762        identity_title_style(),
763    ))];
764    if let Some(period) = report.periods.last() {
765        lines.extend([
766            Line::from(vec![
767                Span::styled(format!("Latest {}: ", period.period), muted_bold_style()),
768                Span::styled(
769                    signed_count(period.estimated_saved),
770                    signed_savings_style(period.estimated_saved),
771                ),
772                Span::raw(" tokens"),
773            ]),
774            Line::from(vec![
775                Span::styled("Window: ", muted_bold_style()),
776                Span::styled(report.window.to_string(), body_style()),
777                Span::raw("   "),
778                Span::styled("Periods: ", muted_bold_style()),
779                value(report.periods.len()),
780            ]),
781            Line::from(vec![
782                Span::styled("Without ", muted_style()),
783                Span::styled(
784                    grouped_count(period.estimated_without_projectatlas),
785                    token_title_style(),
786                ),
787                Span::raw(" - With "),
788                Span::styled(
789                    grouped_count(period.estimated_with_projectatlas),
790                    identity_style(),
791                ),
792                Span::raw(" = Saved "),
793                Span::styled(
794                    signed_count(period.estimated_saved),
795                    signed_savings_style(period.estimated_saved),
796                ),
797            ]),
798            Line::from(vec![
799                Span::styled("Calls: ", muted_bold_style()),
800                value(period.calls),
801                Span::raw("   "),
802                Span::styled("Rate: ", muted_bold_style()),
803                Span::styled(rate_label(period.savings_rate), body_style()),
804            ]),
805        ]);
806    } else {
807        lines.push(Line::from(Span::styled(
808            "Latest: no retained periods",
809            muted_style(),
810        )));
811        lines.push(Line::from(vec![
812            Span::styled("Window: ", muted_bold_style()),
813            Span::styled(report.window.to_string(), body_style()),
814            Span::raw("   "),
815            Span::styled("Periods: ", muted_bold_style()),
816            value(0),
817        ]));
818    }
819    lines.push(Line::from(vec![
820        Span::styled("Estimate: ", muted_bold_style()),
821        Span::styled(report.estimate_scope.as_str(), body_style()),
822    ]));
823    lines.push(Line::from(Span::styled(
824        format!("ProjectAtlas v{}", env!("CARGO_PKG_VERSION")),
825        identity_style(),
826    )));
827    lines
828}
829
830/// Return a semantic compact savings style that preserves negative values.
831fn signed_savings_style(saved: isize) -> Style {
832    Style::default()
833        .fg(if saved < 0 { THEME_RED } else { THEME_GREEN })
834        .add_modifier(Modifier::BOLD)
835}
836
837/// Render one Ratatui frame into a deterministic plain string buffer.
838fn render_dashboard_to_string<F>(width: u16, height: u16, render: F) -> String
839where
840    F: FnOnce(&mut Frame<'_>),
841{
842    let backend = TestBackend::new(width, height);
843    let mut terminal =
844        Terminal::new(backend).expect("in-memory token dashboard backend should initialize");
845    let frame = terminal
846        .draw(render)
847        .expect("in-memory token dashboard should render");
848    buffer_to_string(frame.buffer)
849}
850
851/// Draw the full overview dashboard frame.
852fn render_overview_frame(frame: &mut Frame<'_>, overview: &TokenOverview, session: Option<&str>) {
853    render_overview_frame_with_atlas(frame, overview, session, None);
854}
855
856/// Draw the overview and, when requested and wide enough, its static live atlas.
857fn render_overview_frame_with_atlas(
858    frame: &mut Frame<'_>,
859    overview: &TokenOverview,
860    session: Option<&str>,
861    atlas: Option<&TokenAtlasPreview>,
862) {
863    let area = frame.area();
864    let outer = Block::bordered()
865        .border_set(symbols::border::ROUNDED)
866        .border_style(Style::default().fg(THEME_TEXT))
867        .style(Style::default().fg(THEME_TEXT));
868    let inner = outer.inner(area);
869    frame.render_widget(outer, area);
870    render_window_title_bar(frame, area);
871
872    if area.width < ATLAS_DASHBOARD_MIN_WIDTH || atlas.is_none() {
873        render_overview_main(frame, inner, overview, session);
874        return;
875    }
876    let columns = Layout::default()
877        .direction(Direction::Horizontal)
878        .constraints([
879            Constraint::Length(TOKEN_IMPACT_COLUMN_WIDTH),
880            Constraint::Min(48),
881        ])
882        .split(inner);
883    render_overview_main(frame, columns[0], overview, session);
884    if let Some(atlas) = atlas {
885        render_atlas_map(frame, columns[1], atlas);
886    }
887}
888
889/// Draw the proven one-screen savings overview.
890fn render_overview_main(
891    frame: &mut Frame<'_>,
892    area: Rect,
893    overview: &TokenOverview,
894    session: Option<&str>,
895) {
896    let sections = Layout::default()
897        .direction(Direction::Vertical)
898        .constraints([
899            Constraint::Length(7),
900            Constraint::Length(13),
901            Constraint::Length(8),
902            Constraint::Length(6),
903            Constraint::Min(8),
904            Constraint::Length(4),
905            Constraint::Length(1),
906        ])
907        .split(area);
908
909    render_token_header(frame, sections[0], overview, session);
910    render_token_hero(frame, sections[1], overview);
911    render_avoided_navigation_card(frame, sections[2], overview);
912    render_composition_and_signal(frame, sections[3], overview);
913    render_savings_breakdown_table(frame, sections[4], overview);
914    render_calibration_notes(frame, sections[5], overview);
915    render_status_bar(frame, sections[6]);
916}
917
918/// Return a screenshot-matched dashboard panel.
919fn panel(title: &'static str) -> Block<'static> {
920    let block = Block::bordered()
921        .border_set(symbols::border::ROUNDED)
922        .border_style(Style::default().fg(THEME_TEXT))
923        .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL));
924    if title.is_empty() {
925        block
926    } else {
927        block.title(Span::styled(
928            format!(" {} ", reference_title(title)),
929            section_title_style().bg(THEME_PANEL),
930        ))
931    }
932}
933
934/// Draw the reference-style app title bar and window controls.
935fn render_window_title_bar(frame: &mut Frame<'_>, area: Rect) {
936    if area.width < 8 {
937        return;
938    }
939    let top = Rect {
940        x: area.x.saturating_add(1),
941        y: area.y,
942        width: area.width.saturating_sub(2),
943        height: 1,
944    };
945    let columns = Layout::default()
946        .direction(Direction::Horizontal)
947        .constraints([
948            Constraint::Length(10),
949            Constraint::Min(12),
950            Constraint::Length(10),
951        ])
952        .split(top);
953    frame.render_widget(
954        Paragraph::new(Line::from(vec![
955            Span::styled(" ● ", Style::default().fg(THEME_RED)),
956            Span::styled("● ", Style::default().fg(THEME_YELLOW)),
957            Span::styled("●", Style::default().fg(THEME_GREEN)),
958        ])),
959        columns[0],
960    );
961    frame.render_widget(
962        Paragraph::new("projectatlas -- savings-overview")
963            .style(body_style())
964            .alignment(Alignment::Center),
965        columns[1],
966    );
967}
968
969/// Draw the title band.
970fn render_token_header(
971    frame: &mut Frame<'_>,
972    area: Rect,
973    overview: &TokenOverview,
974    session: Option<&str>,
975) {
976    frame.render_widget(
977        Block::default().style(Style::default().bg(THEME_PANEL)),
978        area,
979    );
980    let columns = Layout::default()
981        .direction(Direction::Horizontal)
982        .constraints([
983            Constraint::Min(42),
984            Constraint::Length(if area.width >= 110 { 46 } else { 34 }),
985        ])
986        .split(area);
987
988    frame.render_widget(
989        Paragraph::new(vec![
990            Line::from(""),
991            Line::from(vec![
992                Span::styled("ProjectAtlas", identity_title_style()),
993                Span::raw(" "),
994                Span::styled("Token Impact", token_title_style()),
995            ]),
996            Line::from(vec![
997                Span::styled("Smarter context. Fewer tokens. ", body_style()),
998                Span::styled("Real savings.", Style::default().fg(THEME_GREEN)),
999            ]),
1000        ])
1001        .style(Style::default().bg(THEME_PANEL))
1002        .wrap(Wrap { trim: true }),
1003        columns[0],
1004    );
1005
1006    frame.render_widget(
1007        Paragraph::new(vec![
1008            Line::from(vec![
1009                Span::styled("Session: ", muted_bold_style()),
1010                Span::styled(session.unwrap_or("all"), body_style()),
1011            ]),
1012            Line::from(vec![
1013                Span::styled("Lookups: ", muted_bold_style()),
1014                Span::styled(grouped_count(overview.calls), body_style()),
1015            ]),
1016            Line::from(vec![
1017                Span::styled("Estimate: ", muted_bold_style()),
1018                Span::styled("local", body_style()),
1019            ]),
1020        ])
1021        .style(Style::default().bg(THEME_PANEL))
1022        .alignment(Alignment::Right)
1023        .wrap(Wrap { trim: true }),
1024        columns[1],
1025    );
1026}
1027
1028/// Draw the dominant saved-token hero panel.
1029fn render_token_hero(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1030    let block = panel("").border_style(Style::default().fg(THEME_TEXT));
1031    let inner = block.inner(area);
1032    frame.render_widget(block, area);
1033
1034    let rows = Layout::default()
1035        .direction(Direction::Vertical)
1036        .constraints([
1037            Constraint::Length(1),
1038            Constraint::Length(2),
1039            Constraint::Length(1),
1040            Constraint::Length(1),
1041            Constraint::Length(3),
1042            Constraint::Min(3),
1043        ])
1044        .split(inner);
1045
1046    frame.render_widget(
1047        Paragraph::new(reference_title("AVERAGE TOKENS AVOIDED"))
1048            .style(section_title_style().bg(THEME_PANEL))
1049            .alignment(Alignment::Center),
1050        rows[0],
1051    );
1052    render_hero_value(frame, rows[1], overview.average_tokens_avoided);
1053    frame.render_widget(
1054        Paragraph::new("Total Tokens Avoided")
1055            .style(body_style().bg(THEME_PANEL))
1056            .alignment(Alignment::Center),
1057        rows[2],
1058    );
1059    render_divider(frame, rows[3]);
1060
1061    let with_projectatlas = usize_to_isize_saturating(overview.estimated_with_projectatlas);
1062    let average = overview.average_tokens_avoided;
1063    render_token_equation(
1064        frame,
1065        rows[4],
1066        reconciled_without_projectatlas(overview),
1067        with_projectatlas,
1068        average,
1069        "Average avoided",
1070        signed_color(average),
1071    );
1072    let maximum = overview.maximum_tokens_avoided;
1073    render_token_equation(
1074        frame,
1075        rows[5],
1076        with_projectatlas.saturating_add(maximum),
1077        with_projectatlas,
1078        maximum,
1079        "Maximum avoided",
1080        if maximum < 0 { THEME_RED } else { THEME_YELLOW },
1081    );
1082}
1083
1084/// Draw one without-minus-with avoided-token equation and its three bars.
1085fn render_token_equation(
1086    frame: &mut Frame<'_>,
1087    area: Rect,
1088    without_projectatlas: isize,
1089    with_projectatlas: isize,
1090    avoided: isize,
1091    avoided_label: &'static str,
1092    avoided_color: Color,
1093) {
1094    let columns = Layout::default()
1095        .direction(Direction::Horizontal)
1096        .constraints([
1097            Constraint::Percentage(30),
1098            Constraint::Length(3),
1099            Constraint::Percentage(30),
1100            Constraint::Length(3),
1101            Constraint::Percentage(30),
1102        ])
1103        .split(area);
1104    let denominator = without_projectatlas.unsigned_abs();
1105
1106    render_metric_column(
1107        frame,
1108        columns[0],
1109        signed_count(without_projectatlas),
1110        "Without ProjectAtlas",
1111        THEME_BLUE,
1112        1.0,
1113    );
1114    frame.render_widget(center_symbol("-"), columns[1]);
1115    render_metric_column(
1116        frame,
1117        columns[2],
1118        signed_count(with_projectatlas),
1119        "With ProjectAtlas",
1120        THEME_INK_WHITE,
1121        ratio(with_projectatlas.unsigned_abs(), denominator),
1122    );
1123    frame.render_widget(center_symbol("="), columns[3]);
1124    render_metric_column(
1125        frame,
1126        columns[4],
1127        signed_count(avoided),
1128        avoided_label,
1129        avoided_color,
1130        ratio(avoided.unsigned_abs(), denominator),
1131    );
1132}
1133
1134/// Draw the saved-token headline as readable terminal text.
1135fn render_hero_value(frame: &mut Frame<'_>, area: Rect, value: isize) {
1136    let text = signed_count(value);
1137    let style = hero_value_style(value);
1138    let marker = hero_state_marker(value);
1139    let line = if area.width >= 48 {
1140        let mut spans = vec![Span::styled(text, style)];
1141        if let Some(marker) = marker {
1142            spans.push(Span::styled(format!("  {marker}"), style));
1143        }
1144        Line::from(spans)
1145    } else {
1146        Line::from(Span::styled(text, style))
1147    };
1148    frame.render_widget(
1149        Paragraph::new(line)
1150            .style(style)
1151            .alignment(Alignment::Center),
1152        area,
1153    );
1154}
1155
1156/// Return the semantic marker used beside the saved-token headline.
1157fn hero_state_marker(value: isize) -> Option<&'static str> {
1158    match value.cmp(&0) {
1159        std::cmp::Ordering::Greater => Some("✓"),
1160        std::cmp::Ordering::Less => Some("!"),
1161        std::cmp::Ordering::Equal => None,
1162    }
1163}
1164
1165/// Draw one metric operand in the hero equation.
1166fn render_metric_column(
1167    frame: &mut Frame<'_>,
1168    area: Rect,
1169    number: String,
1170    label_text: &'static str,
1171    color: Color,
1172    ratio_value: f64,
1173) {
1174    let bar_width = area.width.saturating_sub(2).min(34) as usize;
1175    frame.render_widget(
1176        Paragraph::new(vec![
1177            Line::from(Span::styled(
1178                number,
1179                Style::default()
1180                    .fg(color)
1181                    .bg(THEME_PANEL)
1182                    .add_modifier(Modifier::BOLD),
1183            )),
1184            Line::from(Span::styled(
1185                label_text,
1186                Style::default().fg(color).bg(THEME_PANEL),
1187            )),
1188            block_bar(bar_width, ratio_value, color),
1189        ])
1190        .alignment(Alignment::Center),
1191        area,
1192    );
1193}
1194
1195/// Return a centered operator paragraph.
1196fn center_symbol(symbol: &'static str) -> Paragraph<'static> {
1197    Paragraph::new(symbol).alignment(Alignment::Center).style(
1198        Style::default()
1199            .fg(THEME_TEXT)
1200            .bg(THEME_PANEL)
1201            .add_modifier(Modifier::BOLD),
1202    )
1203}
1204
1205/// Draw the reference-style avoided navigation-work strip.
1206fn render_avoided_navigation_card(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1207    let block = panel("");
1208    let inner = block.inner(area);
1209    frame.render_widget(block, area);
1210    let compact = inner.width < 100;
1211    let rows = Layout::default()
1212        .direction(Direction::Vertical)
1213        .constraints([Constraint::Length(1), Constraint::Min(4)])
1214        .split(inner);
1215    frame.render_widget(
1216        Paragraph::new(reference_title("NAVIGATION WORK AVOIDED"))
1217            .style(section_title_style().bg(THEME_PANEL)),
1218        rows[0],
1219    );
1220
1221    render_file_read_impact_row(frame, rows[1], overview, compact);
1222}
1223
1224/// Draw the source-reconciled file-read total and its observed/modeled split.
1225fn render_file_read_impact_row(
1226    frame: &mut Frame<'_>,
1227    area: Rect,
1228    overview: &TokenOverview,
1229    compact: bool,
1230) {
1231    let total_reads = overview.likely_file_reads_avoided;
1232    let observed_ratio = ratio(overview.observed_file_read_replacements, total_reads);
1233    let modeled_ratio = ratio(overview.modeled_file_reads_avoided, total_reads);
1234    if compact {
1235        let bar_width = area.width.saturating_sub(34).min(24) as usize;
1236        frame.render_widget(
1237            Paragraph::new(vec![
1238                Line::from(vec![
1239                    Span::styled("⌁  File reads avoided: ", body_style().bg(THEME_PANEL)),
1240                    Span::styled(
1241                        grouped_count(total_reads),
1242                        Style::default()
1243                            .fg(THEME_INK_WHITE)
1244                            .bg(THEME_PANEL)
1245                            .add_modifier(Modifier::BOLD),
1246                    ),
1247                    Span::styled("  •  confidence ", muted_style().bg(THEME_PANEL)),
1248                    Span::styled(
1249                        overview.read_avoidance_confidence.clone(),
1250                        Style::default().fg(THEME_YELLOW).bg(THEME_PANEL),
1251                    ),
1252                ]),
1253                impact_bar_line(
1254                    "Observed",
1255                    &format!(
1256                        "{}/{}",
1257                        grouped_count(overview.observed_file_read_replacements),
1258                        grouped_count(total_reads)
1259                    ),
1260                    observed_ratio,
1261                    THEME_INK_WHITE,
1262                    bar_width,
1263                ),
1264                impact_bar_line(
1265                    "Modeled",
1266                    &format!(
1267                        "{}/{}",
1268                        grouped_count(overview.modeled_file_reads_avoided),
1269                        grouped_count(total_reads)
1270                    ),
1271                    modeled_ratio,
1272                    THEME_YELLOW,
1273                    bar_width,
1274                ),
1275                Line::from(Span::styled(
1276                    format!(
1277                        "{} observed + {} modeled = {}",
1278                        grouped_count(overview.observed_file_read_replacements),
1279                        grouped_count(overview.modeled_file_reads_avoided),
1280                        grouped_count(total_reads)
1281                    ),
1282                    muted_style().bg(THEME_PANEL),
1283                )),
1284            ])
1285            .style(body_style().bg(THEME_PANEL)),
1286            area,
1287        );
1288        return;
1289    }
1290
1291    let columns = Layout::default()
1292        .direction(Direction::Horizontal)
1293        .constraints([
1294            Constraint::Percentage(22),
1295            Constraint::Length(1),
1296            Constraint::Percentage(30),
1297            Constraint::Length(1),
1298            Constraint::Percentage(30),
1299            Constraint::Length(1),
1300            Constraint::Percentage(15),
1301        ])
1302        .split(area);
1303    render_file_read_total(frame, columns[0], total_reads);
1304    render_vertical_separator(frame, columns[1]);
1305    render_impact_metric(
1306        frame,
1307        columns[2],
1308        "Observed (summaries/slices)",
1309        overview.observed_file_read_replacements,
1310        total_reads,
1311        observed_ratio,
1312        THEME_INK_WHITE,
1313    );
1314    render_vertical_separator(frame, columns[3]);
1315    render_impact_metric(
1316        frame,
1317        columns[4],
1318        "Search-modeled narrowing",
1319        overview.modeled_file_reads_avoided,
1320        total_reads,
1321        modeled_ratio,
1322        THEME_YELLOW,
1323    );
1324    render_vertical_separator(frame, columns[5]);
1325    frame.render_widget(
1326        Paragraph::new(vec![
1327            Line::from(Span::styled("Confidence", muted_style().bg(THEME_PANEL))),
1328            Line::from(Span::styled(
1329                overview.read_avoidance_confidence.clone(),
1330                Style::default()
1331                    .fg(THEME_YELLOW)
1332                    .bg(THEME_PANEL)
1333                    .add_modifier(Modifier::BOLD),
1334            )),
1335        ])
1336        .style(body_style().bg(THEME_PANEL))
1337        .alignment(Alignment::Center),
1338        columns[6],
1339    );
1340}
1341
1342/// Draw one file-read component with its exact share of the reconciled total.
1343fn render_impact_metric(
1344    frame: &mut Frame<'_>,
1345    area: Rect,
1346    label: &'static str,
1347    value: usize,
1348    total: usize,
1349    ratio_value: f64,
1350    color: Color,
1351) {
1352    frame.render_widget(
1353        Paragraph::new(vec![
1354            Line::from(Span::styled(
1355                label,
1356                Style::default().fg(color).bg(THEME_PANEL),
1357            )),
1358            Line::from(vec![
1359                Span::styled(
1360                    grouped_count(value),
1361                    Style::default()
1362                        .fg(color)
1363                        .bg(THEME_PANEL)
1364                        .add_modifier(Modifier::BOLD),
1365                ),
1366                Span::raw("  "),
1367                Span::styled(
1368                    percentage_label(value, total),
1369                    muted_style().bg(THEME_PANEL),
1370                ),
1371            ]),
1372            block_bar(
1373                area.width.saturating_sub(2).min(32) as usize,
1374                ratio_value,
1375                color,
1376            ),
1377        ])
1378        .style(body_style().bg(THEME_PANEL)),
1379        area,
1380    );
1381}
1382
1383/// Return one labeled, exact ratio and its proportional block bar.
1384fn impact_bar_line(
1385    label: &'static str,
1386    exact: &str,
1387    ratio_value: f64,
1388    color: Color,
1389    bar_width: usize,
1390) -> Line<'static> {
1391    let mut spans = vec![Span::styled(
1392        format!(
1393            "{label}: {exact} • {}  ",
1394            percentage_one_decimal(ratio_value)
1395        ),
1396        body_style().bg(THEME_PANEL),
1397    )];
1398    spans.extend(block_bar(bar_width, ratio_value, color).spans);
1399    Line::from(spans)
1400}
1401
1402/// Draw the left file-read total with the reference document-icon hierarchy.
1403fn render_file_read_total(frame: &mut Frame<'_>, area: Rect, total_reads: usize) {
1404    let columns = Layout::default()
1405        .direction(Direction::Horizontal)
1406        .constraints([Constraint::Length(7), Constraint::Min(8)])
1407        .split(area);
1408    frame.render_widget(
1409        Paragraph::new(vec![
1410            Line::from(Span::styled("╭──╮", identity_style().bg(THEME_PANEL))),
1411            Line::from(Span::styled("│≡ │", identity_style().bg(THEME_PANEL))),
1412            Line::from(Span::styled("╰──╯", identity_style().bg(THEME_PANEL))),
1413        ])
1414        .alignment(Alignment::Center)
1415        .style(body_style().bg(THEME_PANEL)),
1416        columns[0],
1417    );
1418    frame.render_widget(
1419        Paragraph::new(vec![
1420            Line::from(Span::styled(
1421                grouped_count(total_reads),
1422                Style::default()
1423                    .fg(THEME_INK_WHITE)
1424                    .bg(THEME_PANEL)
1425                    .add_modifier(Modifier::BOLD),
1426            )),
1427            Line::from(Span::styled(
1428                "file reads avoided",
1429                muted_style().bg(THEME_PANEL),
1430            )),
1431        ])
1432        .style(body_style().bg(THEME_PANEL)),
1433        columns[1],
1434    );
1435}
1436
1437/// Draw the side-by-side composition and signal cards.
1438fn render_composition_and_signal(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1439    let columns = Layout::default()
1440        .direction(Direction::Horizontal)
1441        .constraints([
1442            Constraint::Percentage(50),
1443            Constraint::Length(1),
1444            Constraint::Percentage(50),
1445        ])
1446        .split(area);
1447    render_savings_composition(frame, columns[0], overview);
1448    render_signal_card(frame, columns[2], overview);
1449}
1450
1451/// Draw observed-vs-modeled token composition.
1452fn render_savings_composition(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1453    let block = panel(if area.width < 42 {
1454        "SAVINGS MIX"
1455    } else {
1456        "SAVINGS COMPOSITION"
1457    });
1458    let inner = block.inner(area);
1459    frame.render_widget(block, area);
1460    let mix = file_handling_token_mix(overview);
1461    let total = mix.total_abs();
1462    let compact = inner.width < 56;
1463    let label_width = if compact { 18 } else { 32 };
1464    let bar_width = inner.width.saturating_sub(label_width + 10).clamp(6, 24) as usize;
1465
1466    let lines = if mix.observed < 0 || mix.modeled < 0 {
1467        vec![
1468            Line::from(Span::styled(
1469                format!(
1470                    "Signed mix: observed {} / modeled {}; net {}",
1471                    signed_count(mix.observed),
1472                    signed_count(mix.modeled),
1473                    signed_count(mix.net())
1474                ),
1475                body_style().bg(THEME_PANEL),
1476            )),
1477            composition_line(
1478                if compact {
1479                    "Measured"
1480                } else {
1481                    "Measured from summaries/slices"
1482                },
1483                ratio(mix.observed_abs, total),
1484                THEME_INK_WHITE,
1485                bar_width,
1486                label_width as usize,
1487            ),
1488            composition_line(
1489                if compact {
1490                    "Navigation"
1491                } else {
1492                    "Navigation narrowing"
1493                },
1494                ratio(mix.modeled_abs, total),
1495                THEME_YELLOW,
1496                bar_width,
1497                label_width as usize,
1498            ),
1499        ]
1500    } else {
1501        vec![
1502            composition_line(
1503                if compact {
1504                    "Measured"
1505                } else {
1506                    "Measured from summaries/slices"
1507                },
1508                ratio(mix.observed_abs, total),
1509                THEME_INK_WHITE,
1510                bar_width,
1511                label_width as usize,
1512            ),
1513            Line::from(Span::styled(
1514                "-".repeat(inner.width as usize),
1515                Style::default().fg(THEME_BORDER).bg(THEME_PANEL),
1516            )),
1517            composition_line(
1518                if compact {
1519                    "Navigation"
1520                } else {
1521                    "Navigation narrowing"
1522                },
1523                ratio(mix.modeled_abs, total),
1524                THEME_YELLOW,
1525                bar_width,
1526                label_width as usize,
1527            ),
1528        ]
1529    };
1530
1531    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner);
1532}
1533
1534/// Return one composition row with a compact bar.
1535fn composition_line(
1536    label_text: &'static str,
1537    value: f64,
1538    color: Color,
1539    bar_width: usize,
1540    label_width: usize,
1541) -> Line<'static> {
1542    let mut spans = vec![
1543        Span::styled(
1544            format!("{label_text:<label_width$}"),
1545            Style::default().fg(color).bg(THEME_PANEL),
1546        ),
1547        Span::raw(" "),
1548    ];
1549    spans.extend(block_bar(bar_width, value, color).spans);
1550    spans.push(Span::raw(" "));
1551    spans.push(Span::styled(
1552        percentage_one_decimal(value),
1553        Style::default()
1554            .fg(color)
1555            .bg(THEME_PANEL)
1556            .add_modifier(Modifier::BOLD),
1557    ));
1558    Line::from(spans)
1559}
1560
1561/// Draw signal metadata from the reference dashboard.
1562fn render_signal_card(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1563    let tokenizer = overview.calibration.as_ref().map_or_else(
1564        || "not run".to_string(),
1565        |calibration| calibration.tokenizer.clone(),
1566    );
1567    frame.render_widget(
1568        Paragraph::new(vec![
1569            Line::from(vec![
1570                Span::styled("▣  ", Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL)),
1571                Span::styled("Impact scope: ", body_style().bg(THEME_PANEL)),
1572                Span::styled(
1573                    "tokens + reads + walks + candidates",
1574                    Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL),
1575                ),
1576            ]),
1577            Line::from(vec![
1578                Span::styled("⌁  ", Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL)),
1579                Span::styled("Estimate type: ", body_style().bg(THEME_PANEL)),
1580                Span::styled(
1581                    "local model",
1582                    Style::default().fg(THEME_YELLOW).bg(THEME_PANEL),
1583                ),
1584            ]),
1585            Line::from(vec![
1586                Span::styled("◇  ", Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL)),
1587                Span::styled("Tokenizer audit: ", body_style().bg(THEME_PANEL)),
1588                Span::styled(tokenizer, body_style().bg(THEME_PANEL)),
1589            ]),
1590        ])
1591        .block(panel("SIGNAL"))
1592        .style(body_style().bg(THEME_PANEL))
1593        .wrap(Wrap { trim: true }),
1594        area,
1595    );
1596}
1597
1598/// Draw the screenshot-style source table.
1599fn render_savings_breakdown_table(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1600    let compact = area.width < 92;
1601    let constraints = if compact {
1602        [
1603            Constraint::Length(22),
1604            Constraint::Length(1),
1605            Constraint::Length(7),
1606            Constraint::Length(1),
1607            Constraint::Length(14),
1608            Constraint::Length(1),
1609            Constraint::Min(14),
1610        ]
1611    } else {
1612        [
1613            Constraint::Length(30),
1614            Constraint::Length(1),
1615            Constraint::Length(10),
1616            Constraint::Length(1),
1617            Constraint::Length(18),
1618            Constraint::Length(1),
1619            Constraint::Min(26),
1620        ]
1621    };
1622    let rows = savings_source_rows_for_width(overview, compact)
1623        .into_iter()
1624        .map(|source| {
1625            Row::new(vec![
1626                Cell::from(format!("{}  {}", source.icon, source.label)),
1627                Cell::from("|"),
1628                Cell::from(grouped_count(source.steps)),
1629                Cell::from("|"),
1630                Cell::from(signed_count(source.tokens)),
1631                Cell::from("|"),
1632                Cell::from(source.meaning),
1633            ])
1634            .style(Style::default().fg(source.color).bg(THEME_PANEL))
1635        })
1636        .collect::<Vec<_>>();
1637    let table = Table::new(rows, constraints)
1638        .header(
1639            Row::new(vec![
1640                "Source",
1641                "|",
1642                "Steps",
1643                "|",
1644                "Tokens Avoided",
1645                "|",
1646                "What it means",
1647            ])
1648            .style(header_style().bg(THEME_PANEL))
1649            .bottom_margin(1),
1650        )
1651        .column_spacing(1)
1652        .block(panel("WHERE THE SAVINGS CAME FROM"));
1653    frame.render_widget(table, area);
1654}
1655
1656/// Draw the bounded live repository constellation in the wide dashboard column.
1657fn render_atlas_map(frame: &mut Frame<'_>, area: Rect, atlas: &TokenAtlasPreview) {
1658    let block = panel("ATLAS MAP");
1659    let inner = block.inner(area);
1660    frame.render_widget(block, area);
1661    let rows = Layout::default()
1662        .direction(Direction::Vertical)
1663        .constraints([Constraint::Min(8), Constraint::Length(2)])
1664        .split(inner);
1665
1666    if !atlas.available {
1667        render_atlas_message(
1668            frame,
1669            rows[0],
1670            "Graph preview unavailable",
1671            "Token-impact data remains available",
1672        );
1673        return;
1674    }
1675    if atlas.edges.is_empty() {
1676        render_atlas_message(
1677            frame,
1678            rows[0],
1679            "No resolved graph links",
1680            "Run projectatlas scan to refresh",
1681        );
1682        return;
1683    }
1684
1685    let layout = atlas_layout(&atlas.edges);
1686    let mut node_order = layout.nodes.iter().collect::<Vec<_>>();
1687    node_order.sort_by(|left, right| {
1688        right
1689            .1
1690            .distance
1691            .cmp(&left.1.distance)
1692            .then_with(|| left.0.cmp(right.0))
1693    });
1694    let canvas = Canvas::default()
1695        .background_color(THEME_PANEL)
1696        .marker(symbols::Marker::Braille)
1697        .x_bounds([-ATLAS_CANVAS_X_BOUND, ATLAS_CANVAS_X_BOUND])
1698        .y_bounds([-ATLAS_CANVAS_Y_BOUND, ATLAS_CANVAS_Y_BOUND])
1699        .paint(|context| {
1700            for edge in &atlas.edges {
1701                let (Some(source), Some(target)) = (
1702                    layout.nodes.get(&edge.source),
1703                    layout.nodes.get(&edge.target),
1704                ) else {
1705                    continue;
1706                };
1707                context.draw(&CanvasLine::new(
1708                    source.x,
1709                    source.y,
1710                    target.x,
1711                    target.y,
1712                    THEME_MUTED,
1713                ));
1714            }
1715            context.layer();
1716            for (node, placement) in &node_order {
1717                let color = if node.as_str() == layout.hub {
1718                    THEME_INK_WHITE
1719                } else {
1720                    atlas_cluster_color(placement.cluster)
1721                };
1722                if node.as_str() == layout.hub {
1723                    context.draw(&Circle::new(placement.x, placement.y, 0.9, THEME_YELLOW));
1724                    let center = [(placement.x, placement.y)];
1725                    context.draw(&Points::new(&center, THEME_INK_WHITE));
1726                } else {
1727                    if placement.degree >= ATLAS_NODE_HALO_DEGREE {
1728                        context.draw(&Circle::new(placement.x, placement.y, 0.5, color));
1729                    }
1730                    let point = [(placement.x, placement.y)];
1731                    context.draw(&Points::new(&point, color));
1732                }
1733            }
1734        });
1735    frame.render_widget(canvas, rows[0]);
1736
1737    let state = if atlas.truncated {
1738        "bounded live graph • sampled snapshot"
1739    } else {
1740        "bounded live graph • static snapshot"
1741    };
1742    frame.render_widget(
1743        Paragraph::new(vec![
1744            Line::from(Span::styled(
1745                format!(
1746                    "{} nodes • {} links",
1747                    grouped_count(atlas.node_count()),
1748                    grouped_count(atlas.edges.len())
1749                ),
1750                body_style().bg(THEME_PANEL),
1751            )),
1752            Line::from(Span::styled(state, muted_style().bg(THEME_PANEL))),
1753        ])
1754        .alignment(Alignment::Center),
1755        rows[1],
1756    );
1757}
1758
1759/// Draw an explicit centered atlas state without substituting decorative data.
1760fn render_atlas_message(frame: &mut Frame<'_>, area: Rect, title: &str, detail: &str) {
1761    let height = 2_u16.min(area.height);
1762    let message_area = Rect {
1763        x: area.x,
1764        y: area
1765            .y
1766            .saturating_add(area.height.saturating_sub(height) / 2),
1767        width: area.width,
1768        height,
1769    };
1770    frame.render_widget(
1771        Paragraph::new(vec![
1772            Line::from(Span::styled(
1773                title.to_string(),
1774                Style::default()
1775                    .fg(THEME_INK_WHITE)
1776                    .bg(THEME_PANEL)
1777                    .add_modifier(Modifier::BOLD),
1778            )),
1779            Line::from(Span::styled(
1780                detail.to_string(),
1781                muted_style().bg(THEME_PANEL),
1782            )),
1783        ])
1784        .alignment(Alignment::Center),
1785        message_area,
1786    );
1787}
1788
1789/// One force-settled node placement with graph-derived depth and cluster cues.
1790#[derive(Clone, Copy, Debug, PartialEq)]
1791struct AtlasNodePlacement {
1792    /// Horizontal Canvas coordinate.
1793    x: f64,
1794    /// Vertical Canvas coordinate.
1795    y: f64,
1796    /// Undirected degree within the bounded preview.
1797    degree: usize,
1798    /// Shortest graph distance from the central hub.
1799    distance: usize,
1800    /// Stable first-hop branch used for cluster coloring.
1801    cluster: usize,
1802}
1803
1804/// Deterministic centered layout for the small resolved-relation projection.
1805struct AtlasLayout {
1806    /// Stable node positions inside the fixed Canvas safety margin.
1807    nodes: BTreeMap<String, AtlasNodePlacement>,
1808    /// Highest-connectivity node anchored at the geometric center.
1809    hub: String,
1810}
1811
1812/// Settle one connected graph with the reusable force engine and center its strongest hub.
1813fn atlas_layout(edges: &[AtlasPreviewEdge]) -> AtlasLayout {
1814    let mut adjacency = BTreeMap::<String, BTreeSet<String>>::new();
1815    for edge in edges {
1816        adjacency
1817            .entry(edge.source.clone())
1818            .or_default()
1819            .insert(edge.target.clone());
1820        adjacency
1821            .entry(edge.target.clone())
1822            .or_default()
1823            .insert(edge.source.clone());
1824    }
1825    let hub = adjacency
1826        .iter()
1827        .max_by(|left, right| {
1828            left.1
1829                .len()
1830                .cmp(&right.1.len())
1831                .then_with(|| right.0.cmp(left.0))
1832        })
1833        .map(|(node, _)| node.clone())
1834        .unwrap_or_default();
1835    if hub.is_empty() {
1836        return AtlasLayout {
1837            nodes: BTreeMap::new(),
1838            hub,
1839        };
1840    }
1841
1842    let mut branch_and_distance = BTreeMap::<String, (usize, usize)>::new();
1843    branch_and_distance.insert(hub.clone(), (0, 0));
1844    let hub_neighbors = adjacency.get(&hub).cloned().unwrap_or_default();
1845    let mut frontier = VecDeque::new();
1846    for (cluster, neighbor) in hub_neighbors.iter().enumerate() {
1847        branch_and_distance.insert(neighbor.clone(), (cluster, 1));
1848        frontier.push_back(neighbor.clone());
1849    }
1850    while let Some(node) = frontier.pop_front() {
1851        let Some((cluster, distance)) = branch_and_distance.get(&node).copied() else {
1852            continue;
1853        };
1854        if let Some(neighbors) = adjacency.get(&node) {
1855            for neighbor in neighbors {
1856                if !branch_and_distance.contains_key(neighbor) {
1857                    branch_and_distance.insert(neighbor.clone(), (cluster, distance + 1));
1858                    frontier.push_back(neighbor.clone());
1859                }
1860            }
1861        }
1862    }
1863
1864    let branch_count = hub_neighbors.len().max(1);
1865    let mut branch_ordinals = BTreeMap::<usize, usize>::new();
1866    let node_names = adjacency.keys().cloned().collect::<Vec<_>>();
1867    let node_indexes = node_names
1868        .iter()
1869        .enumerate()
1870        .map(|(index, node)| (node.clone(), index))
1871        .collect::<BTreeMap<_, _>>();
1872    let mut positions = Vec::with_capacity(node_names.len());
1873    for node in &node_names {
1874        let (cluster, distance) = branch_and_distance.get(node).copied().unwrap_or_default();
1875        let location = if *node == hub {
1876            (0.0, 0.0)
1877        } else {
1878            let ordinal = branch_ordinals.entry(cluster).or_default();
1879            let offset = (*ordinal % 5) as f64 - 2.0;
1880            let ring = (*ordinal / 5) as f64;
1881            *ordinal += 1;
1882            let angle =
1883                std::f64::consts::TAU * cluster as f64 / branch_count as f64 + offset * 0.22;
1884            let radius = 22.0 + distance as f64 * 16.0 + ring * 6.0;
1885            (radius * angle.cos(), radius * angle.sin())
1886        };
1887        positions.push(location);
1888    }
1889    let indexed_edges = edges
1890        .iter()
1891        .filter_map(|edge| {
1892            Some((
1893                *node_indexes.get(&edge.source)?,
1894                *node_indexes.get(&edge.target)?,
1895            ))
1896        })
1897        .collect::<Vec<_>>();
1898    settle_atlas_layout(&mut positions, &indexed_edges);
1899
1900    let hub_location = node_indexes
1901        .get(&hub)
1902        .and_then(|index| positions.get(*index))
1903        .copied()
1904        .unwrap_or_default();
1905    let (max_x, max_y) = positions.iter().fold((0.0_f64, 0.0_f64), |(x, y), node| {
1906        (
1907            x.max((node.0 - hub_location.0).abs()),
1908            y.max((node.1 - hub_location.1).abs()),
1909        )
1910    });
1911    let x_scale = if max_x > f64::EPSILON {
1912        (ATLAS_CANVAS_X_BOUND - 3.0) / max_x
1913    } else {
1914        1.0
1915    };
1916    let y_scale = if max_y > f64::EPSILON {
1917        (ATLAS_CANVAS_Y_BOUND - 3.0) / max_y
1918    } else {
1919        1.0
1920    };
1921    let mut nodes = BTreeMap::new();
1922    for (node, location) in node_names.into_iter().zip(positions) {
1923        let (cluster, distance) = branch_and_distance.get(&node).copied().unwrap_or_default();
1924        nodes.insert(
1925            node.clone(),
1926            AtlasNodePlacement {
1927                x: (location.0 - hub_location.0) * x_scale,
1928                y: (location.1 - hub_location.1) * y_scale,
1929                degree: adjacency.get(&node).map_or(0, BTreeSet::len),
1930                distance,
1931                cluster,
1932            },
1933        );
1934    }
1935    AtlasLayout { nodes, hub }
1936}
1937
1938/// Settle the tiny deterministic preview with bounded Fruchterman-Reingold steps.
1939fn settle_atlas_layout(positions: &mut [(f64, f64)], edges: &[(usize, usize)]) {
1940    let mut displacement = vec![(0.0, 0.0); positions.len()];
1941    for iteration in 0..ATLAS_LAYOUT_ITERATIONS {
1942        displacement.fill((0.0, 0.0));
1943        for left in 0..positions.len() {
1944            for right in left + 1..positions.len() {
1945                let delta = (
1946                    positions[left].0 - positions[right].0,
1947                    positions[left].1 - positions[right].1,
1948                );
1949                let distance = delta.0.hypot(delta.1).max(0.01);
1950                let force = ATLAS_LAYOUT_IDEAL_DISTANCE.powi(2) / distance;
1951                let unit = (delta.0 / distance, delta.1 / distance);
1952                displacement[left].0 += unit.0 * force;
1953                displacement[left].1 += unit.1 * force;
1954                displacement[right].0 -= unit.0 * force;
1955                displacement[right].1 -= unit.1 * force;
1956            }
1957        }
1958        for &(source, target) in edges {
1959            let delta = (
1960                positions[source].0 - positions[target].0,
1961                positions[source].1 - positions[target].1,
1962            );
1963            let distance = delta.0.hypot(delta.1).max(0.01);
1964            let force = distance.powi(2) / ATLAS_LAYOUT_IDEAL_DISTANCE;
1965            let unit = (delta.0 / distance, delta.1 / distance);
1966            displacement[source].0 -= unit.0 * force;
1967            displacement[source].1 -= unit.1 * force;
1968            displacement[target].0 += unit.0 * force;
1969            displacement[target].1 += unit.1 * force;
1970        }
1971        let temperature = (ATLAS_LAYOUT_INITIAL_TEMPERATURE
1972            * (1.0 - iteration as f64 / ATLAS_LAYOUT_ITERATIONS as f64))
1973            .max(0.1);
1974        for (position, delta) in positions.iter_mut().zip(&displacement) {
1975            let distance = delta.0.hypot(delta.1);
1976            if distance > f64::EPSILON {
1977                let step = distance.min(temperature) / distance;
1978                position.0 += delta.0 * step;
1979                position.1 += delta.1 * step;
1980            }
1981        }
1982    }
1983}
1984
1985/// Map graph-derived first-hop branches to the stable dashboard accent palette.
1986const fn atlas_cluster_color(cluster: usize) -> Color {
1987    match cluster % 4 {
1988        0 => THEME_BLUE,
1989        1 => THEME_GREEN,
1990        2 => THEME_YELLOW,
1991        _ => THEME_PURPLE,
1992    }
1993}
1994
1995/// Draw calibration notes without duplicating headline totals.
1996fn render_calibration_notes(frame: &mut Frame<'_>, area: Rect, overview: &TokenOverview) {
1997    let block = panel("CALIBRATION & NOTES");
1998    let inner = block.inner(area);
1999    frame.render_widget(block, area);
2000    let mut lines = vec![
2001        Line::from(Span::styled(
2002            "• Local estimate only; not provider billing data",
2003            body_style().bg(THEME_PANEL),
2004        )),
2005        Line::from(Span::styled(
2006            format!(
2007                "• Observed reads: {}   Modeled narrowing: {}",
2008                grouped_count(overview.observed_file_read_replacements),
2009                grouped_count(overview.modeled_file_reads_avoided)
2010            ),
2011            body_style().bg(THEME_PANEL),
2012        )),
2013    ];
2014    if let Some(value) = overview.calibration.as_ref() {
2015        lines.push(Line::from(Span::styled(
2016            format!(
2017                "• Tokenizer audit: {} over {} files",
2018                value.tokenizer,
2019                grouped_count(value.files)
2020            ),
2021            body_style().bg(THEME_PANEL),
2022        )));
2023    }
2024    if inner.width < 100 {
2025        frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner);
2026        return;
2027    }
2028    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner);
2029}
2030
2031/// Draw the compact footer/status row from the reference dashboard.
2032fn render_status_bar(frame: &mut Frame<'_>, area: Rect) {
2033    let columns = Layout::default()
2034        .direction(Direction::Horizontal)
2035        .constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
2036        .split(area);
2037    frame.render_widget(
2038        Paragraph::new(Line::from(vec![
2039            Span::styled(
2040                "ProjectAtlas v",
2041                Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL),
2042            ),
2043            Span::styled(
2044                env!("CARGO_PKG_VERSION"),
2045                Style::default().fg(THEME_INK_WHITE).bg(THEME_PANEL),
2046            ),
2047        ]))
2048        .style(Style::default().bg(THEME_PANEL)),
2049        columns[0],
2050    );
2051    let clock = current_clock_label();
2052    let status = if area.width < 100 {
2053        format!(
2054            "Snapshot {} • rerun to refresh",
2055            clock.get(..5).unwrap_or(&clock)
2056        )
2057    } else {
2058        format!("Snapshot {clock} • rerun command to refresh")
2059    };
2060    frame.render_widget(
2061        Paragraph::new(Span::styled(status, body_style().bg(THEME_PANEL)))
2062            .style(Style::default().bg(THEME_PANEL))
2063            .alignment(Alignment::Right),
2064        columns[1],
2065    );
2066}
2067
2068/// Render a horizontal divider in a panel.
2069fn render_divider(frame: &mut Frame<'_>, area: Rect) {
2070    frame.render_widget(
2071        Paragraph::new("─".repeat(area.width as usize))
2072            .style(Style::default().fg(THEME_BORDER).bg(THEME_PANEL)),
2073        area,
2074    );
2075}
2076
2077/// Render a vertical separator in a panel.
2078fn render_vertical_separator(frame: &mut Frame<'_>, area: Rect) {
2079    frame.render_widget(
2080        Paragraph::new(vec![
2081            Line::from(Span::styled(
2082                "│",
2083                Style::default().fg(THEME_BORDER).bg(THEME_PANEL),
2084            )),
2085            Line::from(Span::styled(
2086                "│",
2087                Style::default().fg(THEME_BORDER).bg(THEME_PANEL),
2088            )),
2089            Line::from(Span::styled(
2090                "│",
2091                Style::default().fg(THEME_BORDER).bg(THEME_PANEL),
2092            )),
2093        ]),
2094        area,
2095    );
2096}
2097
2098/// Return a segmented bar matching the reference dashboard.
2099fn block_bar(width: usize, ratio_value: f64, color: Color) -> Line<'static> {
2100    let filled = ((width as f64) * ratio_value.clamp(0.0, 1.0)).round() as usize;
2101    let empty = width.saturating_sub(filled);
2102    Line::from(vec![
2103        Span::styled(
2104            "█".repeat(filled),
2105            Style::default().fg(color).bg(THEME_PANEL),
2106        ),
2107        Span::styled(
2108            "░".repeat(empty),
2109            Style::default().fg(THEME_BAR_EMPTY).bg(THEME_PANEL),
2110        ),
2111    ])
2112}
2113
2114/// Signed and absolute token operands shown in the composition panel.
2115#[derive(Clone, Copy)]
2116struct TokenMix {
2117    /// Signed observed summary/slice savings.
2118    observed: isize,
2119    /// Signed deduped modeled navigation savings.
2120    modeled: isize,
2121    /// Absolute observed contribution magnitude.
2122    observed_abs: usize,
2123    /// Absolute modeled contribution magnitude.
2124    modeled_abs: usize,
2125}
2126
2127impl TokenMix {
2128    /// Return the signed net total represented by the visible operands.
2129    fn net(self) -> isize {
2130        self.observed.saturating_add(self.modeled)
2131    }
2132
2133    /// Return the absolute denominator used by composition bars.
2134    fn total_abs(self) -> usize {
2135        self.observed_abs.saturating_add(self.modeled_abs)
2136    }
2137}
2138
2139/// Return the token operands that back the composition panel.
2140fn file_handling_token_mix(overview: &TokenOverview) -> TokenMix {
2141    TokenMix {
2142        observed: overview.measured_tokens_saved,
2143        modeled: overview.average_modeled_tokens_avoided,
2144        observed_abs: overview.measured_tokens_saved.unsigned_abs(),
2145        modeled_abs: overview.average_modeled_tokens_avoided.unsigned_abs(),
2146    }
2147}
2148
2149/// One visible aggregate row for savings-source telemetry.
2150struct SavingsSourceRow {
2151    /// Human label shown in the source table.
2152    label: &'static str,
2153    /// Number of telemetry steps represented by the row.
2154    steps: usize,
2155    /// Estimated saved tokens represented by the row.
2156    tokens: isize,
2157    /// Plain-language explanation for humans.
2158    meaning: &'static str,
2159    /// Compact row icon.
2160    icon: &'static str,
2161    /// Row color used by Ratatui.
2162    color: Color,
2163}
2164
2165/// Aggregate visible accounting with screenshot-aligned labels.
2166fn savings_source_rows_for_width(overview: &TokenOverview, compact: bool) -> Vec<SavingsSourceRow> {
2167    let mut rows = Vec::new();
2168    let observed_steps = observed_source_steps(overview);
2169    if observed_steps > 0 || overview.measured_tokens_saved != 0 {
2170        rows.push(SavingsSourceRow {
2171            label: if compact {
2172                "Summaries/slices"
2173            } else {
2174                "Summaries and slices"
2175            },
2176            steps: observed_steps,
2177            tokens: overview.measured_tokens_saved,
2178            meaning: if compact {
2179                "Files replaced"
2180            } else {
2181                "Compact output replaced file reads"
2182            },
2183            icon: "⌁",
2184            color: THEME_INK_WHITE,
2185        });
2186    }
2187
2188    let modeled_groups = modeled_source_groups(overview);
2189    let modeled_weights = modeled_groups
2190        .iter()
2191        .map(|group| group.gross_tokens.unsigned_abs().max(group.steps))
2192        .collect::<Vec<_>>();
2193    let mut modeled_tokens =
2194        allocate_signed_total(overview.deduped_modeled_tokens_avoided, &modeled_weights);
2195    let folder_discount = overview
2196        .deduped_modeled_tokens_avoided
2197        .saturating_sub(overview.average_modeled_tokens_avoided);
2198    if folder_discount > 0
2199        && let Some((index, _)) = modeled_groups
2200            .iter()
2201            .enumerate()
2202            .find(|(_, group)| group.directory_walk)
2203    {
2204        modeled_tokens[index] = modeled_tokens[index].saturating_sub(folder_discount);
2205    }
2206    for (group, tokens) in modeled_groups.into_iter().zip(modeled_tokens) {
2207        if group.steps == 0 && tokens == 0 {
2208            continue;
2209        }
2210        rows.push(SavingsSourceRow {
2211            label: if compact {
2212                group.compact_label
2213            } else {
2214                group.label
2215            },
2216            steps: group.steps,
2217            tokens,
2218            meaning: if compact {
2219                group.compact_meaning
2220            } else {
2221                group.meaning
2222            },
2223            icon: group.icon,
2224            color: THEME_YELLOW,
2225        });
2226    }
2227
2228    let displayed_steps = rows.iter().map(|row| row.steps).sum::<usize>();
2229    let displayed_tokens = rows.iter().map(|row| row.tokens).sum::<isize>();
2230    let step_remainder = overview.calls.saturating_sub(displayed_steps);
2231    let token_remainder = overview
2232        .average_tokens_avoided
2233        .saturating_sub(displayed_tokens);
2234    if step_remainder > 0 || token_remainder != 0 {
2235        rows.push(SavingsSourceRow {
2236            label: if compact {
2237                "Other savings"
2238            } else {
2239                "Unattributed savings"
2240            },
2241            steps: step_remainder,
2242            tokens: token_remainder,
2243            meaning: if compact {
2244                "Real remainder"
2245            } else {
2246                "Real remainder not tied to visible buckets"
2247            },
2248            icon: "•",
2249            color: THEME_MUTED,
2250        });
2251    }
2252
2253    if rows.is_empty() {
2254        rows.push(SavingsSourceRow {
2255            label: "No telemetry",
2256            steps: 0,
2257            tokens: 0,
2258            meaning: "No token savings recorded",
2259            icon: " ",
2260            color: THEME_MUTED,
2261        });
2262    }
2263    rows
2264}
2265
2266/// Real modeled source bucket aggregated for display.
2267struct ModeledSourceGroup {
2268    /// Full-width row label.
2269    label: &'static str,
2270    /// Narrow-width row label.
2271    compact_label: &'static str,
2272    /// Full-width row explanation.
2273    meaning: &'static str,
2274    /// Narrow-width row explanation.
2275    compact_meaning: &'static str,
2276    /// Compact source icon.
2277    icon: &'static str,
2278    /// Number of telemetry calls in the group.
2279    steps: usize,
2280    /// Gross saved-token contribution before headline dedupe allocation.
2281    gross_tokens: isize,
2282    /// Whether the fixed average policy applies to this folder-scope row.
2283    directory_walk: bool,
2284}
2285
2286impl ModeledSourceGroup {
2287    /// Build an empty display group.
2288    const fn new(
2289        label: &'static str,
2290        compact_label: &'static str,
2291        meaning: &'static str,
2292        compact_meaning: &'static str,
2293        icon: &'static str,
2294        directory_walk: bool,
2295    ) -> Self {
2296        Self {
2297            label,
2298            compact_label,
2299            meaning,
2300            compact_meaning,
2301            icon,
2302            steps: 0,
2303            gross_tokens: 0,
2304            directory_walk,
2305        }
2306    }
2307
2308    /// Add one telemetry bucket to the group.
2309    fn add_bucket(&mut self, bucket: &TokenBucketOverview) {
2310        self.steps = self.steps.saturating_add(bucket.calls);
2311        self.gross_tokens = self.gross_tokens.saturating_add(bucket.estimated_saved);
2312    }
2313}
2314
2315/// Return observed source steps from real observed buckets, with legacy fallback.
2316fn observed_source_steps(overview: &TokenOverview) -> usize {
2317    let bucket_steps = overview
2318        .buckets
2319        .iter()
2320        .filter(|bucket| is_observed_source_bucket(bucket))
2321        .map(|bucket| bucket.calls)
2322        .sum::<usize>();
2323    if bucket_steps == 0 {
2324        overview.observed_file_read_replacements
2325    } else {
2326        bucket_steps
2327    }
2328}
2329
2330/// Return modeled source rows backed by actual telemetry buckets.
2331fn modeled_source_groups(overview: &TokenOverview) -> Vec<ModeledSourceGroup> {
2332    let mut groups = [
2333        ModeledSourceGroup::new(
2334            "Skipped broad folder walk",
2335            "Skipped folder walk",
2336            "Ranking skipped broad folders",
2337            "Folders skipped",
2338            "□",
2339            true,
2340        ),
2341        ModeledSourceGroup::new(
2342            "Opened fewer candidates (A)",
2343            "Fewer candidates A",
2344            "Folder ranking narrowed files",
2345            "Folder shortlist",
2346            "▤",
2347            false,
2348        ),
2349        ModeledSourceGroup::new(
2350            "Opened fewer candidates (B)",
2351            "Fewer candidates B",
2352            "Search/ranking narrowed files",
2353            "Search shortlist",
2354            "▥",
2355            false,
2356        ),
2357        ModeledSourceGroup::new(
2358            "Other modeled narrowing",
2359            "Other narrowing",
2360            "Additional modeled avoidance",
2361            "Other modeled",
2362            "◇",
2363            false,
2364        ),
2365    ];
2366    for bucket in overview
2367        .buckets
2368        .iter()
2369        .filter(|bucket| !is_observed_source_bucket(bucket))
2370    {
2371        let index = modeled_group_index(bucket);
2372        groups[index].add_bucket(bucket);
2373    }
2374    groups
2375        .into_iter()
2376        .filter(|group| group.steps > 0 || group.gross_tokens != 0)
2377        .collect()
2378}
2379
2380/// Pick the reference-style source row for one modeled bucket.
2381fn modeled_group_index(bucket: &TokenBucketOverview) -> usize {
2382    if bucket.denominator_kind == TOKEN_BASELINE_DIRECTORY_WALK {
2383        return 0;
2384    }
2385    match (
2386        bucket.baseline_kind.as_str(),
2387        bucket.denominator_kind.as_str(),
2388    ) {
2389        (TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES) => 1,
2390        (TOKEN_BASELINE_SELECTED_CANDIDATES, _) | (_, TOKEN_BASELINE_SELECTED_CANDIDATES) => 2,
2391        _ => 3,
2392    }
2393}
2394
2395/// Whether a bucket represents observed `ProjectAtlas` file handling.
2396fn is_observed_source_bucket(bucket: &TokenBucketOverview) -> bool {
2397    bucket.accounting_layer == TOKEN_ACCOUNTING_OBSERVED_DELTA
2398        || bucket.token_savings_bucket == TOKEN_BUCKET_FULL_FILE_COMPRESSION
2399        || bucket.baseline_kind == TOKEN_BASELINE_FULL_FILE
2400}
2401
2402/// Allocate a signed display total across real source groups and preserve the exact sum.
2403fn allocate_signed_total(total: isize, weights: &[usize]) -> Vec<isize> {
2404    if weights.is_empty() {
2405        return Vec::new();
2406    }
2407    let total_weight = weights.iter().copied().sum::<usize>();
2408    let effective_weights = if total_weight == 0 {
2409        vec![1; weights.len()]
2410    } else {
2411        weights.to_vec()
2412    };
2413    let effective_total = effective_weights.iter().copied().sum::<usize>();
2414    let mut allocated = Vec::with_capacity(effective_weights.len());
2415    let mut assigned = 0isize;
2416    for (index, weight) in effective_weights.iter().copied().enumerate() {
2417        let value = if index + 1 == effective_weights.len() {
2418            total.saturating_sub(assigned)
2419        } else {
2420            split_signed_by_ratio(total, weight, effective_total)
2421        };
2422        assigned = assigned.saturating_add(value);
2423        allocated.push(value);
2424    }
2425    allocated
2426}
2427
2428/// Split a signed value by a simple integer ratio.
2429fn split_signed_by_ratio(value: isize, part: usize, total: usize) -> isize {
2430    if total == 0 {
2431        return 0;
2432    }
2433    let magnitude = value.unsigned_abs();
2434    let split = magnitude.saturating_mul(part) / total;
2435    if value < 0 {
2436        -(isize::try_from(split).unwrap_or(isize::MAX))
2437    } else {
2438        isize::try_from(split).unwrap_or(isize::MAX)
2439    }
2440}
2441
2442/// Return the screenshot hero's reconciled conservative baseline operand.
2443fn reconciled_without_projectatlas(overview: &TokenOverview) -> isize {
2444    usize_to_isize_saturating(overview.estimated_with_projectatlas)
2445        .saturating_add(overview.average_tokens_avoided)
2446}
2447
2448/// Convert a `usize` to `isize` without panicking on unusually large values.
2449fn usize_to_isize_saturating(value: usize) -> isize {
2450    isize::try_from(value).unwrap_or(isize::MAX)
2451}
2452
2453/// Return the color for a signed value.
2454fn signed_color(value: isize) -> Color {
2455    if value >= 0 { THEME_GREEN } else { THEME_RED }
2456}
2457
2458/// Large positive/negative hero value style.
2459fn hero_value_style(value: isize) -> Style {
2460    Style::default()
2461        .fg(signed_color(value))
2462        .bg(THEME_PANEL)
2463        .add_modifier(Modifier::BOLD)
2464}
2465
2466/// Header style used for panel titles.
2467fn header_style() -> Style {
2468    section_title_style()
2469}
2470
2471/// Section title style used for dashboard chrome.
2472fn section_title_style() -> Style {
2473    Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD)
2474}
2475
2476/// Return the reference-like spaced title treatment used for dominant section labels.
2477fn reference_title(title: &str) -> String {
2478    let mut output = String::with_capacity(title.len().saturating_mul(2));
2479    let mut previous_was_space = false;
2480    for character in title.chars() {
2481        if character == ' ' {
2482            if !previous_was_space {
2483                output.push_str("   ");
2484            }
2485            previous_was_space = true;
2486        } else {
2487            if !output.is_empty() && !previous_was_space {
2488                output.push(' ');
2489            }
2490            output.push(character);
2491            previous_was_space = false;
2492        }
2493    }
2494    output
2495}
2496
2497/// Identity label style.
2498fn identity_style() -> Style {
2499    Style::default()
2500        .fg(THEME_INK_WHITE)
2501        .add_modifier(Modifier::BOLD)
2502}
2503
2504/// `ProjectAtlas` title identity style.
2505fn identity_title_style() -> Style {
2506    Style::default()
2507        .fg(THEME_INK_WHITE)
2508        .add_modifier(Modifier::BOLD)
2509}
2510
2511/// Token Impact title style.
2512fn token_title_style() -> Style {
2513    Style::default().fg(THEME_BLUE).add_modifier(Modifier::BOLD)
2514}
2515
2516/// Body text style.
2517fn body_style() -> Style {
2518    Style::default().fg(THEME_TEXT)
2519}
2520
2521/// Muted text style.
2522fn muted_style() -> Style {
2523    Style::default().fg(THEME_MUTED)
2524}
2525
2526/// Muted bold label style.
2527fn muted_bold_style() -> Style {
2528    muted_style().add_modifier(Modifier::BOLD)
2529}
2530
2531/// Format a percentage with one decimal place.
2532fn percentage_one_decimal(value: f64) -> String {
2533    format!("{:.1}%", value.clamp(0.0, 1.0) * 100.0)
2534}
2535
2536/// Return a compact clock label for the footer status row.
2537fn current_clock_label() -> String {
2538    let seconds_since_epoch = SystemTime::now()
2539        .duration_since(UNIX_EPOCH)
2540        .map_or(0, |duration| duration.as_secs());
2541    let seconds_today = seconds_since_epoch % 86_400;
2542    let hours = seconds_today / 3_600;
2543    let minutes = (seconds_today % 3_600) / 60;
2544    let seconds = seconds_today % 60;
2545    format!("{hours:02}:{minutes:02}:{seconds:02}")
2546}
2547
2548/// Convert trend periods into signed chart coordinates.
2549fn signed_trend_points(periods: Option<&[TokenTrendPeriod]>) -> Vec<(f64, f64)> {
2550    let mut points = periods
2551        .unwrap_or_default()
2552        .iter()
2553        .enumerate()
2554        .map(|(index, period)| (index as f64, period.estimated_saved as f64))
2555        .collect::<Vec<_>>();
2556    if points.is_empty() {
2557        vec![(0.0, 0.0)]
2558    } else if points.len() == 1 {
2559        points.push((1.0, points[0].1));
2560        points
2561    } else {
2562        points
2563    }
2564}
2565
2566/// Return y-axis bounds that preserve the sign of trend values and include zero.
2567fn signed_y_bounds(points: &[(f64, f64)]) -> [f64; 2] {
2568    let min_value = points
2569        .iter()
2570        .map(|(_, value)| *value)
2571        .fold(0.0_f64, f64::min);
2572    let max_value = points
2573        .iter()
2574        .map(|(_, value)| *value)
2575        .fold(0.0_f64, f64::max);
2576    if (min_value - max_value).abs() < f64::EPSILON {
2577        [min_value - 1.0, max_value + 1.0]
2578    } else {
2579        [min_value, max_value]
2580    }
2581}
2582
2583/// Return a trend color that signals all-loss or mixed-sign series.
2584fn signed_trend_color(points: &[(f64, f64)]) -> Color {
2585    let has_positive = points.iter().any(|(_, value)| *value > 0.0);
2586    let has_negative = points.iter().any(|(_, value)| *value < 0.0);
2587    match (has_positive, has_negative) {
2588        (true, true) => THEME_YELLOW,
2589        (false, true) => THEME_RED,
2590        _ => THEME_GREEN,
2591    }
2592}
2593
2594/// Draw the full trend dashboard frame.
2595fn render_trend_frame(frame: &mut Frame<'_>, report: &TokenTrendReport) {
2596    let area = frame.area();
2597    let outer = Block::bordered()
2598        .border_set(symbols::border::ROUNDED)
2599        .title(Line::from(vec![
2600            Span::styled(" ProjectAtlas Token Trends ", identity_title_style()),
2601            Span::styled(format!("{} ", report.window), body_style()),
2602        ]))
2603        .border_style(Style::default().fg(THEME_TEXT))
2604        .style(Style::default().fg(THEME_TEXT));
2605    let inner = outer.inner(area);
2606    frame.render_widget(outer, area);
2607
2608    let sections = Layout::default()
2609        .direction(Direction::Vertical)
2610        .constraints([
2611            Constraint::Length(3),
2612            Constraint::Length(8),
2613            Constraint::Min(12),
2614            Constraint::Length(4),
2615        ])
2616        .split(inner);
2617
2618    let summary = vec![
2619        Line::from(vec![
2620            label("session"),
2621            Span::raw(report.session.as_deref().unwrap_or("all sessions")),
2622            Span::raw("   "),
2623            label("window"),
2624            Span::raw(report.window.to_string()),
2625            Span::raw("   "),
2626            label("periods"),
2627            value(report.periods.len()),
2628        ]),
2629        Line::from(vec![label("estimate"), Span::raw(&report.estimate_scope)]),
2630    ];
2631    frame.render_widget(Paragraph::new(summary), sections[0]);
2632
2633    let trend_points = signed_trend_points(Some(&report.periods));
2634    let [lower, upper] = signed_y_bounds(&trend_points);
2635    frame.render_widget(
2636        Chart::new(vec![
2637            Dataset::default()
2638                .marker(symbols::Marker::Braille)
2639                .graph_type(GraphType::Line)
2640                .style(Style::default().fg(signed_trend_color(&trend_points)))
2641                .data(&trend_points),
2642        ])
2643        .block(panel("SAVED TOKENS TREND"))
2644        .x_axis(Axis::default().bounds([0.0, (trend_points.len().saturating_sub(1)) as f64]))
2645        .y_axis(Axis::default().bounds([lower, upper])),
2646        sections[1],
2647    );
2648
2649    render_trend_table(frame, sections[2], report);
2650    frame.render_widget(
2651        Paragraph::new(
2652            "Trend rows are period gross estimates. Use overview mode for deduped tokens avoided.",
2653        )
2654        .style(body_style().bg(THEME_PANEL))
2655        .alignment(Alignment::Center)
2656        .block(panel("NOTE")),
2657        sections[3],
2658    );
2659}
2660
2661/// Draw period rows for the trend dashboard.
2662fn render_trend_table(frame: &mut Frame<'_>, area: Rect, report: &TokenTrendReport) {
2663    let mut rows = report
2664        .periods
2665        .iter()
2666        .rev()
2667        .take(8)
2668        .map(|period| {
2669            Row::new(vec![
2670                Cell::from(period.period.clone()),
2671                Cell::from(signed_count(period.estimated_saved)),
2672                Cell::from(rate_label(period.savings_rate)),
2673                Cell::from(grouped_count(period.calls)),
2674                Cell::from(grouped_count(period.estimated_without_projectatlas)),
2675                Cell::from(grouped_count(period.estimated_with_projectatlas)),
2676            ])
2677        })
2678        .collect::<Vec<_>>();
2679    rows.reverse();
2680    if rows.is_empty() {
2681        rows.push(Row::new(vec![
2682            Cell::from("none"),
2683            Cell::from("0"),
2684            Cell::from("unknown"),
2685            Cell::from("0"),
2686            Cell::from("0"),
2687            Cell::from("0"),
2688        ]));
2689    }
2690    let table = Table::new(
2691        rows,
2692        [
2693            Constraint::Percentage(18),
2694            Constraint::Percentage(16),
2695            Constraint::Percentage(13),
2696            Constraint::Percentage(10),
2697            Constraint::Percentage(21),
2698            Constraint::Percentage(22),
2699        ],
2700    )
2701    .header(
2702        Row::new(vec![
2703            "period", "saved", "rate", "calls", "baseline", "emitted",
2704        ])
2705        .style(Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD)),
2706    )
2707    .block(panel("PERIODS"));
2708    frame.render_widget(table, area);
2709}
2710
2711/// Convert a Ratatui buffer into trimmed terminal text.
2712fn buffer_to_string(buffer: &Buffer) -> String {
2713    let width = buffer.area.width;
2714    let height = buffer.area.height;
2715    let mut lines = Vec::with_capacity(height as usize);
2716    for y in 0..height {
2717        let mut line = String::new();
2718        for x in 0..width {
2719            if let Some(cell) = buffer.cell((x, y)) {
2720                line.push_str(cell.symbol());
2721            }
2722        }
2723        lines.push(line.trim_end().to_string());
2724    }
2725    while matches!(lines.last(), Some(line) if line.is_empty()) {
2726        lines.pop();
2727    }
2728    let mut output = lines.join("\n");
2729    output.push('\n');
2730    output
2731}
2732
2733/// Convert a Ratatui buffer into ANSI-styled terminal text.
2734fn buffer_to_ansi_string(buffer: &Buffer) -> String {
2735    let width = buffer.area.width;
2736    let height = buffer.area.height;
2737    let mut output = String::new();
2738    let mut active_style: Option<CellAnsiStyle> = None;
2739    for y in 0..height {
2740        let mut x = 0;
2741        while x < width {
2742            let Some(cell) = buffer.cell((x, y)) else {
2743                x = x.saturating_add(1);
2744                continue;
2745            };
2746            let style = CellAnsiStyle::from_cell(cell);
2747            if active_style != Some(style) {
2748                output.push_str("\x1b[0m");
2749                output.push_str(&style.to_ansi());
2750                active_style = Some(style);
2751            }
2752            output.push_str(cell.symbol());
2753            x = x.saturating_add(cell.symbol().cell_width().max(1));
2754        }
2755        output.push_str("\x1b[0m");
2756        if y + 1 < height {
2757            output.push('\n');
2758        }
2759        active_style = None;
2760    }
2761    output
2762}
2763
2764/// Minimal style projection used by the ANSI serializer.
2765#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2766struct CellAnsiStyle {
2767    /// Cell foreground color.
2768    fg: Color,
2769    /// Cell background color.
2770    bg: Color,
2771    /// Cell modifiers.
2772    modifier: Modifier,
2773}
2774
2775impl CellAnsiStyle {
2776    /// Build a style projection from one rendered Ratatui cell.
2777    fn from_cell(cell: &ratatui::buffer::Cell) -> Self {
2778        Self {
2779            fg: themed_color(cell.fg),
2780            bg: themed_color(cell.bg),
2781            modifier: cell.modifier,
2782        }
2783    }
2784
2785    /// Convert the style to ANSI Select Graphic Rendition escapes.
2786    fn to_ansi(self) -> String {
2787        let mut codes = Vec::new();
2788        if self.modifier.contains(Modifier::BOLD) {
2789            codes.push("1".to_string());
2790        }
2791        if self.modifier.contains(Modifier::ITALIC) {
2792            codes.push("3".to_string());
2793        }
2794        if self.modifier.contains(Modifier::UNDERLINED) {
2795            codes.push("4".to_string());
2796        }
2797        if let Some(code) = color_to_ansi(self.fg, false) {
2798            codes.push(code);
2799        }
2800        if let Some(code) = color_to_ansi(self.bg, true) {
2801            codes.push(code);
2802        }
2803        if codes.is_empty() {
2804            String::new()
2805        } else {
2806            format!("\x1b[{}m", codes.join(";"))
2807        }
2808    }
2809}
2810
2811/// Convert one Ratatui color into foreground/background ANSI code.
2812fn color_to_ansi(color: Color, background: bool) -> Option<String> {
2813    let offset = if background { 10 } else { 0 };
2814    let code = match color {
2815        Color::Reset => return None,
2816        Color::Black => 30 + offset,
2817        Color::Red => 31 + offset,
2818        Color::Green => 32 + offset,
2819        Color::Yellow => 33 + offset,
2820        Color::Blue => 34 + offset,
2821        Color::Magenta => 35 + offset,
2822        Color::Cyan => 36 + offset,
2823        Color::Gray | Color::White => 37 + offset,
2824        Color::DarkGray => 90 + offset,
2825        Color::LightRed => 91 + offset,
2826        Color::LightGreen => 92 + offset,
2827        Color::LightYellow => 93 + offset,
2828        Color::LightBlue => 94 + offset,
2829        Color::LightMagenta => 95 + offset,
2830        Color::LightCyan => 96 + offset,
2831        Color::Rgb(red, green, blue) => {
2832            let prefix = if background { 48 } else { 38 };
2833            return Some(format!("{prefix};2;{red};{green};{blue}"));
2834        }
2835        Color::Indexed(index) => {
2836            let prefix = if background { 48 } else { 38 };
2837            return Some(format!("{prefix};5;{index}"));
2838        }
2839    };
2840    Some(code.to_string())
2841}
2842
2843/// Remap the dark reference palette to the selected output palette.
2844fn themed_color(color: Color) -> Color {
2845    match active_token_theme() {
2846        TokenDashboardTheme::Dark => color,
2847        TokenDashboardTheme::Light => remap_to_light_theme(color),
2848        TokenDashboardTheme::Terminal => match color {
2849            THEME_BG | THEME_PANEL | THEME_TEXT | THEME_MUTED | THEME_INK_WHITE => Color::Reset,
2850            _ => color,
2851        },
2852    }
2853}
2854
2855/// Convert one dark semantic role color into its light-theme counterpart.
2856fn remap_to_light_theme(color: Color) -> Color {
2857    match color {
2858        THEME_BG => LIGHT_THEME.bg,
2859        THEME_PANEL => LIGHT_THEME.panel,
2860        THEME_TEXT => LIGHT_THEME.text,
2861        THEME_MUTED => LIGHT_THEME.muted,
2862        THEME_INK_WHITE => LIGHT_THEME.ink_white,
2863        THEME_BLUE => LIGHT_THEME.blue,
2864        THEME_GREEN => LIGHT_THEME.green,
2865        THEME_YELLOW => LIGHT_THEME.yellow,
2866        THEME_BORDER => LIGHT_THEME.border,
2867        THEME_BAR_EMPTY => LIGHT_THEME.bar_empty,
2868        THEME_RED => LIGHT_THEME.red,
2869        THEME_PURPLE => LIGHT_THEME.purple,
2870        _ => color,
2871    }
2872}
2873
2874/// Styled field label span.
2875fn label(text: &str) -> Span<'static> {
2876    Span::styled(format!("{text}: "), muted_bold_style())
2877}
2878
2879/// Styled unsigned value span.
2880fn value(value: usize) -> Span<'static> {
2881    Span::styled(grouped_count(value), identity_style())
2882}
2883
2884/// Format an optional savings rate.
2885fn rate_label(value: Option<f64>) -> String {
2886    value.map_or_else(
2887        || "unknown".to_string(),
2888        |rate| format!("{:.1}%", rate * 100.0),
2889    )
2890}
2891
2892/// Format one part of a whole as a nearest integer percentage.
2893fn percentage_label(part: usize, total: usize) -> String {
2894    if total == 0 {
2895        "0%".to_string()
2896    } else {
2897        format!("{:.0}%", (part as f64 / total as f64) * 100.0)
2898    }
2899}
2900
2901/// Return a stable ratio for Ratatui gauges.
2902fn ratio(part: usize, total: usize) -> f64 {
2903    if total == 0 {
2904        0.0
2905    } else {
2906        (part as f64 / total as f64).clamp(0.0, 1.0)
2907    }
2908}
2909
2910/// Preserve the established width-only policy for plain agent payloads.
2911fn dashboard_width() -> usize {
2912    let columns = std::env::var("COLUMNS")
2913        .ok()
2914        .and_then(|value| value.parse::<usize>().ok());
2915    let terminal_width = ratatui::crossterm::terminal::size()
2916        .ok()
2917        .map(|(width, _)| width);
2918    resolve_dashboard_width(columns, terminal_width)
2919}
2920
2921/// Resolve an explicit plain-payload width before using the detected terminal width.
2922fn resolve_dashboard_width(columns: Option<usize>, terminal_width: Option<u16>) -> usize {
2923    columns
2924        .or_else(|| terminal_width.map(usize::from))
2925        .unwrap_or(usize::from(DASHBOARD_DEFAULT_WIDTH))
2926}
2927
2928/// Parse one non-zero terminal dimension from the environment.
2929fn dashboard_environment_dimension(name: &str) -> Option<u16> {
2930    std::env::var(name)
2931        .ok()
2932        .and_then(|value| value.parse::<u16>().ok())
2933        .filter(|value| *value > 0)
2934}
2935
2936/// Resolve live terminal dimensions before deterministic environment fallbacks.
2937fn resolve_dashboard_viewport(
2938    terminal_size: Option<(u16, u16)>,
2939    environment_columns: Option<u16>,
2940    environment_rows: Option<u16>,
2941) -> TokenDashboardViewport {
2942    let columns = terminal_size
2943        .and_then(|(columns, _)| NonZeroU16::new(columns))
2944        .or_else(|| environment_columns.and_then(NonZeroU16::new))
2945        .unwrap_or(NonZeroU16::new(DASHBOARD_DEFAULT_WIDTH).unwrap_or(NonZeroU16::MIN));
2946    let rows = terminal_size
2947        .and_then(|(_, rows)| NonZeroU16::new(rows))
2948        .or_else(|| environment_rows.and_then(NonZeroU16::new))
2949        .unwrap_or(NonZeroU16::new(DASHBOARD_HEIGHT).unwrap_or(NonZeroU16::MIN));
2950    TokenDashboardViewport { columns, rows }
2951}
2952
2953/// Format an unsigned count with thousands separators.
2954fn grouped_count(value: usize) -> String {
2955    let raw = value.to_string();
2956    let mut grouped = String::with_capacity(raw.len() + raw.len() / 3);
2957    for (index, character) in raw.chars().enumerate() {
2958        if index > 0 && (raw.len() - index).is_multiple_of(3) {
2959            grouped.push(',');
2960        }
2961        grouped.push(character);
2962    }
2963    grouped
2964}
2965
2966/// Format a signed count with thousands separators.
2967fn signed_count(value: isize) -> String {
2968    if value < 0 {
2969        format!("-{}", grouped_count(value.unsigned_abs()))
2970    } else {
2971        grouped_count(usize::try_from(value).unwrap_or(usize::MAX))
2972    }
2973}
2974
2975#[cfg(test)]
2976mod tests {
2977    use super::{
2978        ATLAS_CANVAS_X_BOUND, ATLAS_CANVAS_Y_BOUND, ATLAS_PREVIEW_MAX_EDGES,
2979        ATLAS_PREVIEW_MAX_NODE_DEGREE, ATLAS_PREVIEW_MAX_NODES, DASHBOARD_HEIGHT, THEME_BAR_EMPTY,
2980        THEME_BG, THEME_BLUE, THEME_GREEN, THEME_INK_WHITE, THEME_YELLOW,
2981        TOKEN_IMPACT_COLUMN_WIDTH, TokenAtlasPreview, TokenDashboardTheme, TokenDashboardViewport,
2982        atlas_layout, block_bar, buffer_to_ansi_string, buffer_to_string, grouped_count,
2983        reconciled_without_projectatlas, reference_title, render_dashboard_to_string,
2984        render_overview_frame, render_overview_frame_with_atlas, render_token_dashboard,
2985        render_token_dashboard_with_atlas, render_token_dashboard_with_theme,
2986        render_token_trend_dashboard, render_token_trend_dashboard_with_theme,
2987        render_token_trend_dashboard_with_theme_in_viewport, resolve_dashboard_viewport,
2988        resolve_dashboard_width, savings_source_rows_for_width, signed_count, signed_trend_points,
2989        signed_y_bounds, token_dashboard_wants_atlas,
2990    };
2991    use projectatlas_core::graph::GraphRelationKind;
2992    use projectatlas_core::symbols::RelationKind;
2993    use projectatlas_core::telemetry::{
2994        AgentEfficiencyComparison, AgentEfficiencyEvidenceState,
2995        TOKEN_ACCOUNTING_MODELED_AVOIDANCE, TOKEN_BASELINE_DIRECTORY_WALK,
2996        TOKEN_BASELINE_SELECTED_CANDIDATES, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
2997        TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE, TOKEN_DEDUPE_SCOPE_SESSION,
2998        TokenOverview, TokenTrendPeriod, TokenTrendReport, TokenTrendWindow, usage_from_estimates,
2999        usage_from_estimates_with_accounting, usage_from_text,
3000    };
3001    use ratatui::Terminal;
3002    use ratatui::backend::TestBackend;
3003    use ratatui::buffer::{Buffer, CellWidth};
3004    use ratatui::layout::Rect;
3005    use ratatui::style::Style;
3006    use ratatui::style::{Color, Modifier};
3007    use ratatui::text::Line;
3008    use std::collections::{BTreeMap, BTreeSet, VecDeque};
3009
3010    #[test]
3011    fn plain_dashboard_width_preserves_explicit_terminal_and_default_precedence() {
3012        assert_eq!(resolve_dashboard_width(Some(200), Some(190)), 200);
3013        assert_eq!(resolve_dashboard_width(None, Some(190)), 190);
3014        assert_eq!(resolve_dashboard_width(None, None), 140);
3015    }
3016
3017    #[test]
3018    fn dashboard_viewport_prefers_live_dimensions_then_valid_fallbacks() {
3019        assert_eq!(
3020            viewport_dimensions(resolve_dashboard_viewport(
3021                Some((80, 24)),
3022                Some(200),
3023                Some(60),
3024            )),
3025            (80, 24)
3026        );
3027        assert_eq!(
3028            viewport_dimensions(resolve_dashboard_viewport(None, Some(100), Some(20),)),
3029            (100, 20)
3030        );
3031        assert_eq!(
3032            viewport_dimensions(resolve_dashboard_viewport(
3033                Some((0, 24)),
3034                Some(100),
3035                Some(0),
3036            )),
3037            (100, 24)
3038        );
3039        assert_eq!(
3040            viewport_dimensions(resolve_dashboard_viewport(Some((80, 0)), Some(0), Some(20),)),
3041            (80, 20)
3042        );
3043        assert_eq!(
3044            viewport_dimensions(resolve_dashboard_viewport(None, Some(0), Some(0),)),
3045            (140, 50)
3046        );
3047    }
3048
3049    #[test]
3050    fn dashboard_viewport_selects_full_layout_and_atlas_at_exact_boundaries() {
3051        for (columns, rows, full_overview, full_trend, atlas) in [
3052            (79, 50, false, false, false),
3053            (80, 29, false, false, false),
3054            (80, 30, false, true, false),
3055            (80, 49, false, true, false),
3056            (80, 50, true, true, false),
3057            (189, 50, true, true, false),
3058            (190, 49, false, true, false),
3059            (190, 50, true, true, true),
3060            (200, 50, true, true, true),
3061        ] {
3062            let viewport = test_viewport(columns, rows);
3063            assert_eq!(viewport.fits_overview(), full_overview);
3064            assert_eq!(viewport.fits_trend(), full_trend);
3065            assert_eq!(token_dashboard_wants_atlas(viewport), atlas);
3066        }
3067    }
3068
3069    #[test]
3070    fn compact_overview_is_bounded_and_preserves_facts_by_priority() {
3071        let overview = sample_overview();
3072        let atlas = TokenAtlasPreview::empty();
3073        for (columns, rows) in [(79, 50), (80, 49), (40, 4), (1, 1)] {
3074            let viewport = test_viewport(columns, rows);
3075            let dashboard = rendered_dashboard(render_token_dashboard_with_atlas(
3076                &overview,
3077                Some("s"),
3078                &atlas,
3079                TokenDashboardTheme::Dark,
3080                viewport,
3081            ));
3082            assert_ansi_bounds(&dashboard, viewport);
3083            assert!(!strip_ansi(&dashboard).contains(&reference_title("AVERAGE TOKENS AVOIDED")));
3084        }
3085
3086        let dashboard = rendered_dashboard(render_token_dashboard_with_atlas(
3087            &overview,
3088            Some("s"),
3089            &atlas,
3090            TokenDashboardTheme::Dark,
3091            test_viewport(79, 8),
3092        ));
3093        let dashboard = strip_ansi(&dashboard);
3094        for required in [
3095            "ProjectAtlas Token Impact",
3096            "Average avoided:",
3097            "Without",
3098            "File reads:",
3099            "Token mix:",
3100            "Lookups:",
3101            "Estimate:",
3102            "ProjectAtlas v",
3103        ] {
3104            assert!(
3105                dashboard.contains(required),
3106                "missing compact fact {required:?}"
3107            );
3108        }
3109    }
3110
3111    #[test]
3112    fn full_dashboard_layouts_remain_compatible_at_minimum_dimensions() {
3113        let overview = sample_overview();
3114        let atlas = TokenAtlasPreview::empty();
3115        let overview_viewport = test_viewport(80, 50);
3116        let dashboard = rendered_dashboard(render_token_dashboard_with_atlas(
3117            &overview,
3118            Some("s"),
3119            &atlas,
3120            TokenDashboardTheme::Dark,
3121            overview_viewport,
3122        ));
3123        assert_ansi_bounds(&dashboard, overview_viewport);
3124        assert!(!dashboard.ends_with('\n'));
3125        let dashboard = strip_ansi(&dashboard);
3126        assert!(dashboard.contains(&reference_title("AVERAGE TOKENS AVOIDED")));
3127        assert!(dashboard.contains(&reference_title("WHERE THE SAVINGS CAME FROM")));
3128
3129        let trend_viewport = test_viewport(80, 30);
3130        let trend = rendered_dashboard(render_token_trend_dashboard_with_theme_in_viewport(
3131            &sample_trend_report(),
3132            TokenDashboardTheme::Dark,
3133            trend_viewport,
3134        ));
3135        assert_ansi_bounds(&trend, trend_viewport);
3136        assert!(!trend.ends_with('\n'));
3137        assert!(strip_ansi(&trend).contains(&reference_title("SAVED TOKENS TREND")));
3138    }
3139
3140    #[test]
3141    fn compact_overview_and_trend_preserve_negative_savings() {
3142        let mut folder =
3143            usage_from_estimates("s", "folders", Some("src".to_string()), None, 101, 60);
3144        folder.denominator_kind = TOKEN_BASELINE_DIRECTORY_WALK.to_string();
3145        let overview = TokenOverview::from_events(&[folder]);
3146        assert_eq!(overview.average_tokens_avoided, -10);
3147        let compact_overview = rendered_dashboard(render_token_dashboard_with_atlas(
3148            &overview,
3149            Some("s"),
3150            &TokenAtlasPreview::empty(),
3151            TokenDashboardTheme::Dark,
3152            test_viewport(60, 8),
3153        ));
3154        let compact_overview = strip_ansi(&compact_overview);
3155        assert!(compact_overview.contains("Average avoided: -10"));
3156        assert!(!compact_overview.contains('✓'));
3157
3158        let trend = TokenTrendReport::new(
3159            Some("s".to_string()),
3160            TokenTrendWindow::Month,
3161            vec![TokenTrendPeriod::from_totals(
3162                "2026-08".to_string(),
3163                1,
3164                50,
3165                100,
3166            )],
3167        );
3168        let compact_trend =
3169            rendered_dashboard(render_token_trend_dashboard_with_theme_in_viewport(
3170                &trend,
3171                TokenDashboardTheme::Dark,
3172                test_viewport(60, 7),
3173            ));
3174        let compact_trend = strip_ansi(&compact_trend);
3175        assert!(compact_trend.contains("Latest 2026-08: -50 tokens"));
3176        assert!(!compact_trend.contains('✓'));
3177    }
3178
3179    #[test]
3180    fn compact_dashboards_preserve_semantic_styles_across_themes() {
3181        let mut folder =
3182            usage_from_estimates("s", "folders", Some("src".to_string()), None, 101, 60);
3183        folder.denominator_kind = TOKEN_BASELINE_DIRECTORY_WALK.to_string();
3184        let overview = TokenOverview::from_events(&[folder]);
3185        let overview_buffer =
3186            render_compact_lines_buffer(super::compact_overview_lines(&overview, Some("s")), 60, 8);
3187        let trend = TokenTrendReport::new(
3188            Some("s".to_string()),
3189            TokenTrendWindow::Month,
3190            vec![TokenTrendPeriod::from_totals(
3191                "2026-08".to_string(),
3192                1,
3193                50,
3194                100,
3195            )],
3196        );
3197        let trend_buffer = render_compact_lines_buffer(super::compact_trend_lines(&trend), 60, 7);
3198
3199        for (theme, loss_color) in [
3200            (TokenDashboardTheme::Dark, super::THEME_RED),
3201            (TokenDashboardTheme::Light, super::LIGHT_THEME.red),
3202            (TokenDashboardTheme::Terminal, super::THEME_RED),
3203        ] {
3204            assert_themed_cell_style(&overview_buffer, "-10", theme, loss_color, Modifier::BOLD);
3205            assert_themed_cell_style(&trend_buffer, "-50", theme, loss_color, Modifier::BOLD);
3206        }
3207    }
3208
3209    #[test]
3210    fn compact_trend_is_bounded_below_each_full_dimension() {
3211        let report = sample_trend_report();
3212        for (columns, rows) in [(79, 30), (80, 29), (40, 4), (1, 1)] {
3213            let viewport = test_viewport(columns, rows);
3214            let dashboard =
3215                rendered_dashboard(render_token_trend_dashboard_with_theme_in_viewport(
3216                    &report,
3217                    TokenDashboardTheme::Dark,
3218                    viewport,
3219                ));
3220            assert_ansi_bounds(&dashboard, viewport);
3221            assert!(!strip_ansi(&dashboard).contains(&reference_title("SAVED TOKENS TREND")));
3222        }
3223
3224        let empty_report = TokenTrendReport::new(None, TokenTrendWindow::Month, Vec::new());
3225        let viewport = test_viewport(40, 3);
3226        let dashboard = rendered_dashboard(render_token_trend_dashboard_with_theme_in_viewport(
3227            &empty_report,
3228            TokenDashboardTheme::Dark,
3229            viewport,
3230        ));
3231        assert_ansi_bounds(&dashboard, viewport);
3232        assert!(strip_ansi(&dashboard).contains("Latest: no retained periods"));
3233    }
3234
3235    #[test]
3236    fn overview_dashboard_matches_reference_sections_and_order() {
3237        let overview = sample_overview();
3238        let dashboard = strip_ansi(&render_token_dashboard(&overview, Some("s")));
3239
3240        for text in [
3241            "ProjectAtlas",
3242            "Token Impact",
3243            "Smarter context. Fewer tokens. Real savings.",
3244            "Session:",
3245            "Lookups:",
3246            "Estimate:",
3247            "Total Tokens Avoided",
3248            "Without ProjectAtlas",
3249            "With ProjectAtlas",
3250            "Average avoided",
3251            "Maximum avoided",
3252            "file reads avoided",
3253            "Observed (summaries/slices)",
3254            "Search-modeled narrowing",
3255            "Confidence",
3256            "Measured from summaries/slices",
3257            "Navigation narrowing",
3258            "Impact scope:",
3259            "tokens + reads + walks + candidates",
3260            "Estimate type: local model",
3261            "Tokenizer audit:",
3262            "Source",
3263            "Steps",
3264            "Tokens Avoided",
3265            "What it means",
3266            "Summaries and slices",
3267            "Skipped broad folder walk",
3268            "Opened fewer candidates (A)",
3269            "Opened fewer candidates (B)",
3270            "Snapshot",
3271            "rerun command to refresh",
3272        ] {
3273            assert!(
3274                dashboard.contains(text),
3275                "dashboard should contain {text:?}"
3276            );
3277        }
3278        assert!(!dashboard.contains("Broad folder walks skipped"));
3279        assert!(!dashboard.contains("Candidate files not opened"));
3280        assert!(!dashboard.contains("source steps account for"));
3281        for title in [
3282            "AVERAGE TOKENS AVOIDED",
3283            "NAVIGATION WORK AVOIDED",
3284            "SAVINGS COMPOSITION",
3285            "SIGNAL",
3286            "WHERE THE SAVINGS CAME FROM",
3287            "CALIBRATION & NOTES",
3288        ] {
3289            assert!(dashboard.contains(&reference_title(title)));
3290        }
3291
3292        assert!(!dashboard.contains("q  Quit"));
3293        assert!(!dashboard.contains("?  Help"));
3294        assert!(!dashboard.contains("r  Refresh"));
3295        assert!(!dashboard.contains("Auto"));
3296        assert!(!dashboard.contains(&reference_title("REPEATED-WORK BENCHMARK")));
3297        assert!(!dashboard.contains(&reference_title("REQUESTED BENCHMARK EVIDENCE")));
3298        assert!(!dashboard.contains("Frozen v0.3.26"));
3299        assert!(!dashboard.contains("Plain Codex control"));
3300        assert!(!dashboard.contains("ProjectAtlas Savings Overview"));
3301        assert!(!dashboard.contains("Saved-token trends"));
3302        assert!(!dashboard.contains("Calibration optional"));
3303        assert!(!dashboard.contains("--tokenizer o200k_base"));
3304        assert!(!dashboard.contains("day trend"));
3305        assert!(!dashboard.contains("week trend"));
3306        assert!(!dashboard.contains("month trend"));
3307        assert!(!dashboard.contains("year trend"));
3308        assert!(dashboard_contains_time(&dashboard));
3309
3310        assert_in_order(
3311            &dashboard,
3312            &[
3313                "ProjectAtlas",
3314                &reference_title("AVERAGE TOKENS AVOIDED"),
3315                "Average avoided",
3316                "Maximum avoided",
3317                &reference_title("NAVIGATION WORK AVOIDED"),
3318                &reference_title("SAVINGS COMPOSITION"),
3319                &reference_title("WHERE THE SAVINGS CAME FROM"),
3320                &reference_title("CALIBRATION & NOTES"),
3321            ],
3322        );
3323        assert_header_margin(&dashboard, "Source", "Summaries and slices");
3324    }
3325
3326    #[test]
3327    fn overview_dashboard_renders_complete_version_footer_at_supported_widths() {
3328        let overview = sample_overview();
3329        let expected_footer = format!("ProjectAtlas v{}", env!("CARGO_PKG_VERSION"));
3330
3331        for width in [80, 140, 200] {
3332            let buffer = render_overview_buffer_at_width(&overview, Some("s"), width);
3333            let rows = (0..buffer.area.height)
3334                .map(|y| line_symbols(&buffer, y))
3335                .collect::<Vec<_>>();
3336            assert_eq!(
3337                rows.iter()
3338                    .filter(|row| row.contains(&expected_footer))
3339                    .count(),
3340                1,
3341                "{width}-column overview must contain exactly one complete version footer"
3342            );
3343            assert_eq!(
3344                rows.iter()
3345                    .map(|row| row.matches("ProjectAtlas v").count())
3346                    .sum::<usize>(),
3347                1,
3348                "{width}-column overview must not duplicate or clip the version footer"
3349            );
3350        }
3351    }
3352
3353    #[test]
3354    fn overview_dashboard_light_theme_remaps_semantic_palette() {
3355        let overview = sample_overview();
3356        let dashboard =
3357            render_token_dashboard_with_theme(&overview, Some("s"), TokenDashboardTheme::Light);
3358
3359        assert!(dashboard.contains("\x1b["));
3360        assert!(
3361            dashboard.contains("48;2;246;242;232"),
3362            "light theme should use the light panel background"
3363        );
3364        assert!(
3365            dashboard.contains("38;2;37;99;235"),
3366            "baseline blue should be remapped for light terminals"
3367        );
3368        assert!(
3369            dashboard.contains("38;2;22;128;72"),
3370            "saved green should be remapped for light terminals"
3371        );
3372        assert!(
3373            dashboard.contains("38;2;178;116;0"),
3374            "modeled yellow should be remapped for light terminals"
3375        );
3376        assert!(
3377            !dashboard.contains("48;2;5;16;25"),
3378            "light theme should not serialize the dark panel background"
3379        );
3380    }
3381
3382    #[test]
3383    fn trend_dashboard_light_theme_remaps_semantic_palette() {
3384        let report = sample_trend_report();
3385        let dashboard = rendered_dashboard(render_token_trend_dashboard_with_theme_in_viewport(
3386            &report,
3387            TokenDashboardTheme::Light,
3388            test_viewport(140, 30),
3389        ));
3390
3391        assert!(dashboard.contains("\x1b["));
3392        assert!(
3393            dashboard.contains("48;2;246;242;232"),
3394            "light trend theme should use the light panel background"
3395        );
3396        assert!(
3397            dashboard.contains("38;2;22;128;72"),
3398            "positive trend line should use the light saved green"
3399        );
3400        assert!(
3401            dashboard.contains("38;2;22;22;20"),
3402            "ProjectAtlas trend title should use the light identity color"
3403        );
3404        assert!(
3405            !dashboard.contains("38;5;14") && !dashboard.contains("38;5;6"),
3406            "trend theme should not serialize hard-coded cyan"
3407        );
3408        assert!(
3409            !dashboard.contains("48;2;5;16;25"),
3410            "light trend theme should not serialize the dark panel background"
3411        );
3412    }
3413
3414    #[test]
3415    fn overview_dashboard_uses_reference_ratatui_styles() {
3416        let overview = sample_overview();
3417        let buffer = render_overview_buffer(&overview, Some("s"));
3418
3419        let Some((title_x, title_y)) = find_text(&buffer, "ProjectAtlas") else {
3420            unreachable!("ProjectAtlas title should render");
3421        };
3422        assert!(
3423            title_x <= 4,
3424            "title should start at the left of the header; title started at x={title_x}"
3425        );
3426        assert!(
3427            title_y <= 4,
3428            "title should stay in the upper header band; title started at y={title_y}"
3429        );
3430        assert_cell_style(&buffer, "ProjectAtlas", THEME_INK_WHITE, Modifier::BOLD);
3431        assert_cell_style(&buffer, "Token Impact", THEME_BLUE, Modifier::BOLD);
3432        assert_cell_style(
3433            &buffer,
3434            &reference_title("AVERAGE TOKENS AVOIDED"),
3435            super::THEME_TEXT,
3436            Modifier::BOLD,
3437        );
3438        assert_cell_style(
3439            &buffer,
3440            &signed_count(overview.tokens_avoided),
3441            THEME_GREEN,
3442            Modifier::BOLD,
3443        );
3444        assert_cell_style(
3445            &buffer,
3446            &signed_count(reconciled_without_projectatlas(&overview)),
3447            THEME_BLUE,
3448            Modifier::BOLD,
3449        );
3450        assert_cell_style(
3451            &buffer,
3452            "Without ProjectAtlas",
3453            THEME_BLUE,
3454            Modifier::empty(),
3455        );
3456        assert_cell_style(
3457            &buffer,
3458            "With ProjectAtlas",
3459            THEME_INK_WHITE,
3460            Modifier::empty(),
3461        );
3462        assert_cell_style(&buffer, "Average avoided", THEME_GREEN, Modifier::empty());
3463        assert_cell_style(&buffer, "Maximum avoided", THEME_YELLOW, Modifier::empty());
3464        assert_cell_style(
3465            &buffer,
3466            "Observed (summaries/slices)",
3467            THEME_INK_WHITE,
3468            Modifier::empty(),
3469        );
3470        assert_cell_style(
3471            &buffer,
3472            "Measured from summaries/slices",
3473            THEME_INK_WHITE,
3474            Modifier::empty(),
3475        );
3476        assert_cell_style(
3477            &buffer,
3478            "Navigation narrowing",
3479            THEME_YELLOW,
3480            Modifier::empty(),
3481        );
3482        assert_cell_style(
3483            &buffer,
3484            "Summaries and slices",
3485            THEME_INK_WHITE,
3486            Modifier::empty(),
3487        );
3488        assert_cell_style(
3489            &buffer,
3490            "Skipped broad folder walk",
3491            THEME_YELLOW,
3492            Modifier::empty(),
3493        );
3494        assert_cell_style(
3495            &buffer,
3496            "Search-modeled narrowing",
3497            THEME_YELLOW,
3498            Modifier::empty(),
3499        );
3500    }
3501
3502    #[test]
3503    fn dashboards_preserve_terminal_background_outside_panels() {
3504        let overview = sample_overview();
3505        let overview_buffer = render_overview_buffer(&overview, Some("s"));
3506        assert_no_terminal_canvas_fill(&overview_buffer);
3507        assert_eq!(
3508            overview_buffer.cell((0, 0)).map(|cell| cell.bg),
3509            Some(Color::Reset),
3510            "outer overview border must not force a dashboard background color"
3511        );
3512
3513        let overview_dark =
3514            render_token_dashboard_with_theme(&overview, Some("s"), TokenDashboardTheme::Dark);
3515        assert!(
3516            !overview_dark.contains("48;2;4;10;18"),
3517            "dark overview output must not paint the terminal canvas"
3518        );
3519
3520        let overview_light =
3521            render_token_dashboard_with_theme(&overview, Some("s"), TokenDashboardTheme::Light);
3522        assert!(
3523            !overview_light.contains("48;2;252;249;241"),
3524            "light overview output must not paint the terminal canvas"
3525        );
3526        let overview_terminal =
3527            render_token_dashboard_with_theme(&overview, Some("s"), TokenDashboardTheme::Terminal);
3528        assert!(
3529            !overview_terminal.contains("48;2;5;16;25"),
3530            "terminal overview theme must preserve the terminal background inside panels"
3531        );
3532        let terminal_neutral_roles = super::with_token_theme(TokenDashboardTheme::Terminal, || {
3533            (
3534                super::themed_color(super::THEME_TEXT),
3535                super::themed_color(super::THEME_MUTED),
3536                super::themed_color(THEME_INK_WHITE),
3537            )
3538        });
3539        assert_eq!(
3540            terminal_neutral_roles,
3541            (Color::Reset, Color::Reset, Color::Reset),
3542            "terminal overview theme must use the terminal foreground for neutral text"
3543        );
3544
3545        let report = sample_trend_report();
3546        let trend_buffer = render_trend_buffer(&report);
3547        assert_no_terminal_canvas_fill(&trend_buffer);
3548        assert_eq!(
3549            trend_buffer.cell((0, 0)).map(|cell| cell.bg),
3550            Some(Color::Reset),
3551            "outer trend border must not force a dashboard background color"
3552        );
3553
3554        let trend_dark = rendered_dashboard(render_token_trend_dashboard_with_theme(
3555            &report,
3556            TokenDashboardTheme::Dark,
3557        ));
3558        assert!(
3559            !trend_dark.contains("48;2;4;10;18"),
3560            "dark trend output must not paint the terminal canvas"
3561        );
3562
3563        let trend_light = rendered_dashboard(render_token_trend_dashboard_with_theme(
3564            &report,
3565            TokenDashboardTheme::Light,
3566        ));
3567        assert!(
3568            !trend_light.contains("48;2;252;249;241"),
3569            "light trend output must not paint the terminal canvas"
3570        );
3571        let trend_terminal = rendered_dashboard(render_token_trend_dashboard_with_theme(
3572            &report,
3573            TokenDashboardTheme::Terminal,
3574        ));
3575        assert!(
3576            !trend_terminal.contains("48;2;5;16;25"),
3577            "terminal trend theme must preserve the terminal background inside panels"
3578        );
3579    }
3580
3581    #[test]
3582    fn overview_dashboard_hero_value_is_readable_terminal_text() {
3583        let overview = TokenOverview::from_estimated_totals(3, 241_563_877, 4_749_368);
3584        let narrow_buffer = render_overview_buffer_at_width(&overview, Some("s"), 100);
3585        let Some((_, narrow_title_y)) =
3586            find_text(&narrow_buffer, &reference_title("AVERAGE TOKENS AVOIDED"))
3587        else {
3588            unreachable!("hero title should render");
3589        };
3590        let narrow_value_line = line_symbols(&narrow_buffer, narrow_title_y + 1);
3591
3592        assert!(
3593            narrow_value_line.contains(&signed_count(overview.tokens_avoided)),
3594            "narrow hero value should fall back to the exact saved-token number as normal terminal text"
3595        );
3596        assert!(
3597            narrow_value_line.contains('✓'),
3598            "narrow hero value should keep the reference-style saved-state marker"
3599        );
3600
3601        let buffer = render_overview_buffer_at_width(&overview, Some("s"), 140);
3602        let Some((_, title_y)) = find_text(&buffer, &reference_title("AVERAGE TOKENS AVOIDED"))
3603        else {
3604            unreachable!("hero title should render");
3605        };
3606        let hero_rows = ((title_y + 1)..=(title_y + 2).min(buffer.area.height.saturating_sub(1)))
3607            .map(|y| line_symbols(&buffer, y))
3608            .collect::<Vec<_>>()
3609            .join("\n");
3610        assert!(
3611            hero_rows.contains(&signed_count(overview.tokens_avoided)),
3612            "wide hero value should render the exact saved-token number as normal terminal text"
3613        );
3614        assert!(
3615            hero_rows.contains('✓'),
3616            "wide hero should draw the saved-state marker beside the readable total"
3617        );
3618        assert!(
3619            !hero_rows.chars().any(|character| {
3620                ('\u{1cc00}'..='\u{1cfff}').contains(&character)
3621                    || ('\u{1fb00}'..='\u{1fbff}').contains(&character)
3622            }),
3623            "wide hero value should avoid dense segmented glyphs that render inconsistently across terminals"
3624        );
3625        let caption_line = line_symbols(&buffer, title_y + 3);
3626        assert!(caption_line.contains("Total Tokens Avoided"));
3627        assert!(
3628            !caption_line.contains(&signed_count(overview.tokens_avoided)),
3629            "caption should label the hero without duplicating the numeric value"
3630        );
3631
3632        let dashboard = render_dashboard_to_string(140, DASHBOARD_HEIGHT, |frame| {
3633            render_overview_frame(frame, &overview, Some("s"));
3634        });
3635        assert!(dashboard.contains("Total Tokens Avoided"));
3636        assert!(
3637            !dashboard.contains(&format!(
3638                "{} tokens avoided",
3639                signed_count(overview.tokens_avoided)
3640            )),
3641            "caption should not duplicate the saved-token number already shown as the hero and saved operand"
3642        );
3643    }
3644
3645    #[test]
3646    fn overview_dashboard_duplicates_the_complete_average_and_maximum_equations() {
3647        let overview = sample_overview();
3648        for width in [80, 140, 190] {
3649            let buffer = render_overview_buffer_at_width(&overview, Some("s"), width);
3650            let Some((_, title_y)) = find_text(&buffer, &reference_title("AVERAGE TOKENS AVOIDED"))
3651            else {
3652                unreachable!("average hero should render at width {width}");
3653            };
3654            let Some((_, average_y)) = find_text(&buffer, "Average avoided") else {
3655                unreachable!("average comparison row should render at width {width}");
3656            };
3657            let Some((_, maximum_y)) = find_text(&buffer, "Maximum avoided") else {
3658                unreachable!("maximum comparison row should render at width {width}");
3659            };
3660            assert_eq!(maximum_y, average_y + 3);
3661
3662            let with_projectatlas =
3663                super::usize_to_isize_saturating(overview.estimated_with_projectatlas);
3664            let equations = [
3665                (
3666                    average_y,
3667                    reconciled_without_projectatlas(&overview),
3668                    overview.average_tokens_avoided,
3669                    "Average avoided",
3670                ),
3671                (
3672                    maximum_y,
3673                    with_projectatlas.saturating_add(overview.maximum_tokens_avoided),
3674                    overview.maximum_tokens_avoided,
3675                    "Maximum avoided",
3676                ),
3677            ];
3678            let text_in = |area: super::Rect| {
3679                let mut text = String::new();
3680                for y in area.y..area.y.saturating_add(area.height) {
3681                    for x in area.x..area.x.saturating_add(area.width) {
3682                        if let Some(cell) = buffer.cell((x, y)) {
3683                            text.push_str(cell.symbol());
3684                        }
3685                    }
3686                    text.push('\n');
3687                }
3688                text
3689            };
3690
3691            for (label_y, without_projectatlas, avoided, avoided_label) in equations {
3692                assert_eq!(
3693                    without_projectatlas.saturating_sub(with_projectatlas),
3694                    avoided
3695                );
3696                let equation =
3697                    super::Rect::new(2, label_y.saturating_sub(1), width.saturating_sub(4), 3);
3698                let columns = super::Layout::default()
3699                    .direction(super::Direction::Horizontal)
3700                    .constraints([
3701                        super::Constraint::Percentage(30),
3702                        super::Constraint::Length(3),
3703                        super::Constraint::Percentage(30),
3704                        super::Constraint::Length(3),
3705                        super::Constraint::Percentage(30),
3706                    ])
3707                    .split(equation);
3708                let value_y = label_y.saturating_sub(1);
3709                let bar_y = label_y + 1;
3710
3711                assert!(
3712                    text_in(super::Rect::new(columns[0].x, value_y, columns[0].width, 1,))
3713                        .contains(&signed_count(without_projectatlas))
3714                );
3715                assert!(text_in(columns[0]).contains("Without ProjectAtlas"));
3716                assert!(text_in(columns[1]).contains('-'));
3717                assert!(
3718                    text_in(super::Rect::new(columns[2].x, value_y, columns[2].width, 1,))
3719                        .contains(&signed_count(with_projectatlas))
3720                );
3721                assert!(text_in(columns[2]).contains("With ProjectAtlas"));
3722                assert!(text_in(columns[3]).contains('='));
3723                assert!(
3724                    text_in(super::Rect::new(columns[4].x, value_y, columns[4].width, 1,))
3725                        .contains(&signed_count(avoided))
3726                );
3727                assert!(text_in(columns[4]).contains(avoided_label));
3728                for column in [columns[0], columns[2], columns[4]] {
3729                    assert!(
3730                        text_in(super::Rect::new(column.x, bar_y, column.width, 1))
3731                            .chars()
3732                            .any(|character| matches!(character, '█' | '░')),
3733                        "every metric column needs its own bar at width {width}"
3734                    );
3735                }
3736            }
3737
3738            let hero = (title_y..=maximum_y + 1)
3739                .map(|y| line_symbols(&buffer, y))
3740                .collect::<Vec<_>>()
3741                .join("\n");
3742            assert!(hero.contains("Total Tokens Avoided"));
3743            assert!(!hero.contains('%'));
3744            for forbidden in [
3745                &reference_title("MAXIMUM TOKENS AVOIDED"),
3746                "Maximum:",
3747                "50% folder-navigation policy",
3748                "all files in avoided folder scopes",
3749                "all folder files",
3750                "other savings unchanged",
3751            ] {
3752                assert!(
3753                    !hero.contains(forbidden),
3754                    "hero should omit obsolete copy {forbidden:?} at width {width}"
3755                );
3756            }
3757        }
3758    }
3759
3760    #[test]
3761    fn overview_dashboard_uses_compact_reference_table_at_narrow_width() {
3762        let overview = sample_overview();
3763        let dashboard = render_dashboard_to_string(80, DASHBOARD_HEIGHT, |frame| {
3764            render_overview_frame(frame, &overview, Some("s"));
3765        });
3766
3767        assert!(dashboard.contains("ProjectAtlas"));
3768        assert!(dashboard.contains("Token Impact"));
3769        assert!(dashboard.contains(&reference_title("AVERAGE TOKENS AVOIDED")));
3770        assert!(dashboard.contains("Total Tokens Avoided"));
3771        assert!(dashboard.contains("Average avoided"));
3772        assert!(dashboard.contains("Maximum avoided"));
3773        assert!(dashboard.contains(&reference_title("NAVIGATION WORK AVOIDED")));
3774        assert!(dashboard.contains(&reference_title("SAVINGS MIX")));
3775        assert!(!dashboard.contains(&reference_title("SAVINGS COMPOSITION")));
3776        assert!(dashboard.contains(&reference_title("WHERE THE SAVINGS CAME FROM")));
3777        assert!(dashboard.contains("Skipped folder walk"));
3778        assert!(dashboard.contains("Fewer candidates B"));
3779        assert!(!dashboard.contains("Broad folder walks skipped"));
3780        assert!(!dashboard.contains("Candidate files not opened"));
3781        assert!(dashboard.contains(&reference_title("CALIBRATION & NOTES")));
3782        assert!(!dashboard.contains(&reference_title("REPEATED-WORK BENCHMARK")));
3783        assert!(!dashboard.contains("Saved-token trends"));
3784    }
3785
3786    #[test]
3787    fn benchmark_evidence_never_changes_the_human_overview() {
3788        let live = sample_overview();
3789        for state in [
3790            AgentEfficiencyEvidenceState::Unavailable,
3791            AgentEfficiencyEvidenceState::Failed,
3792            AgentEfficiencyEvidenceState::Incompatible,
3793            AgentEfficiencyEvidenceState::Partial,
3794            AgentEfficiencyEvidenceState::Compatible,
3795        ] {
3796            let mut with_benchmark = live.clone();
3797            with_benchmark.agent_efficiency = AgentEfficiencyComparison {
3798                state,
3799                reason: Some(
3800                    "structured benchmark evidence remains available to agents".to_string(),
3801                ),
3802                artifact: None,
3803                baselines: Vec::new(),
3804                capabilities: Vec::new(),
3805                provider_counters_descriptive_only: true,
3806            };
3807            for width in [80, 140, 200] {
3808                let live = normalize_dashboard_clock(buffer_to_string(
3809                    &render_overview_buffer_at_width(&live, Some("s"), width),
3810                ));
3811                let with_benchmark = normalize_dashboard_clock(buffer_to_string(
3812                    &render_overview_buffer_at_width(&with_benchmark, Some("s"), width),
3813                ));
3814                assert_eq!(with_benchmark, live);
3815                assert!(!with_benchmark.contains("BENCHMARK"));
3816                assert!(!with_benchmark.contains("Frozen v0.3.26"));
3817                assert!(!with_benchmark.contains("Plain Codex control"));
3818            }
3819        }
3820    }
3821
3822    #[test]
3823    fn overview_dashboard_fields_use_consistent_accounting_layers() {
3824        let overview = sample_overview();
3825        let average_avoided = overview.tokens_avoided;
3826        let with_projectatlas = overview.estimated_with_projectatlas as isize;
3827        let without_projectatlas = reconciled_without_projectatlas(&overview);
3828
3829        assert_eq!(overview.average_tokens_avoided, 204);
3830        assert_eq!(overview.maximum_tokens_avoided, 264);
3831        assert_eq!(overview.tokens_avoided, overview.average_tokens_avoided);
3832
3833        assert_eq!(without_projectatlas - with_projectatlas, average_avoided);
3834        assert_eq!(
3835            overview.measured_tokens_saved + overview.average_modeled_tokens_avoided,
3836            average_avoided
3837        );
3838        assert_eq!(
3839            overview.measured_tokens_saved + overview.deduped_modeled_tokens_avoided,
3840            overview.maximum_tokens_avoided
3841        );
3842        assert_eq!(
3843            overview.observed_file_read_replacements + overview.modeled_file_reads_avoided,
3844            overview.likely_file_reads_avoided
3845        );
3846        let dashboard = strip_ansi(&render_token_dashboard(&overview, Some("s")));
3847        let source_rows = savings_source_rows_for_width(&overview, false);
3848        let source_steps = source_rows.iter().map(|row| row.steps).sum::<usize>();
3849        let source_tokens = source_rows.iter().map(|row| row.tokens).sum::<isize>();
3850
3851        assert_eq!(
3852            source_rows
3853                .iter()
3854                .find(|row| row.label == "Skipped broad folder walk")
3855                .map(|row| row.steps),
3856            Some(1)
3857        );
3858        assert_eq!(
3859            source_rows
3860                .iter()
3861                .find(|row| row.label == "Skipped broad folder walk")
3862                .map(|row| row.tokens),
3863            Some(40)
3864        );
3865        assert_eq!(
3866            source_rows
3867                .iter()
3868                .find(|row| row.label == "Opened fewer candidates (A)")
3869                .map(|row| row.tokens),
3870            Some(64)
3871        );
3872        assert_eq!(
3873            source_rows
3874                .iter()
3875                .find(|row| row.label == "Opened fewer candidates (B)")
3876                .map(|row| row.tokens),
3877            Some(80)
3878        );
3879        assert_eq!(source_steps, overview.calls);
3880        assert_eq!(source_tokens, average_avoided);
3881        assert!(!dashboard.contains("Broad folder walks skipped"));
3882        assert!(!dashboard.contains("Candidate files not opened"));
3883        assert!(dashboard.contains(&signed_count(without_projectatlas)));
3884        assert!(dashboard.contains(&signed_count(with_projectatlas)));
3885        assert!(dashboard.contains(&signed_count(average_avoided)));
3886        assert!(dashboard.contains(&signed_count(overview.maximum_tokens_avoided)));
3887        assert_eq!(
3888            dashboard.matches(&signed_count(average_avoided)).count(),
3889            2,
3890            "wide dashboard should show the saved total as readable hero text and as the equation result"
3891        );
3892        assert!(dashboard.contains(&grouped_count(overview.likely_file_reads_avoided)));
3893        assert!(dashboard.contains(&grouped_count(overview.observed_file_read_replacements)));
3894        assert!(dashboard.contains(&grouped_count(overview.modeled_file_reads_avoided)));
3895    }
3896
3897    #[test]
3898    fn overview_dashboard_source_table_reconciles_unattributed_remainder() {
3899        let mut overview = sample_overview();
3900        overview.buckets.clear();
3901        overview.calls = 7;
3902        overview.measured_tokens_saved = 11;
3903        overview.deduped_modeled_tokens_avoided = 29;
3904        overview.average_modeled_tokens_avoided = 29;
3905        overview.average_tokens_avoided = 40;
3906        overview.maximum_tokens_avoided = 40;
3907        overview.tokens_avoided = 40;
3908
3909        let rows = savings_source_rows_for_width(&overview, false);
3910        let source_steps = rows.iter().map(|row| row.steps).sum::<usize>();
3911        let source_tokens = rows.iter().map(|row| row.tokens).sum::<isize>();
3912
3913        assert_eq!(source_steps, overview.calls);
3914        assert_eq!(source_tokens, overview.tokens_avoided);
3915        assert!(rows.iter().any(|row| row.label == "Unattributed savings"));
3916
3917        let dashboard = strip_ansi(&render_token_dashboard(&overview, Some("s")));
3918        assert!(dashboard.contains("Unattributed savings"));
3919    }
3920
3921    #[test]
3922    fn overview_dashboard_discounts_every_directory_walk_denominator() {
3923        let overview = TokenOverview::from_events(&[usage_from_estimates_with_accounting(
3924            "s",
3925            "folders",
3926            None,
3927            Some("src".to_string()),
3928            120,
3929            20,
3930            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3931            TOKEN_BASELINE_SELECTED_CANDIDATES,
3932            TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3933            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
3934            TOKEN_BASELINE_DIRECTORY_WALK,
3935            TOKEN_DEDUPE_SCOPE_SESSION,
3936        )]);
3937
3938        let rows = savings_source_rows_for_width(&overview, false);
3939
3940        assert_eq!(overview.average_tokens_avoided, 40);
3941        assert_eq!(overview.maximum_tokens_avoided, 100);
3942        assert_eq!(
3943            rows.iter()
3944                .find(|row| row.label == "Skipped broad folder walk")
3945                .map(|row| row.tokens),
3946            Some(40)
3947        );
3948        assert_eq!(
3949            rows.iter().map(|row| row.tokens).sum::<isize>(),
3950            overview.average_tokens_avoided
3951        );
3952        assert!(!rows.iter().any(|row| row.label == "Unattributed savings"));
3953    }
3954
3955    #[test]
3956    fn overview_dashboard_token_mix_percentages_follow_saved_token_operands() {
3957        let overview = TokenOverview::from_events(&[
3958            usage_from_text(
3959                "s",
3960                "summary",
3961                Some("src/lib.rs".to_string()),
3962                None,
3963                &"x".repeat(400),
3964                &"x".repeat(320),
3965            ),
3966            usage_from_estimates("s", "search", None, Some("token".to_string()), 100, 20),
3967        ]);
3968
3969        assert_eq!(overview.measured_tokens_saved, 20);
3970        assert_eq!(overview.deduped_modeled_tokens_avoided, 80);
3971        assert_eq!(overview.tokens_avoided, 100);
3972
3973        let dashboard = strip_ansi(&render_token_dashboard(&overview, Some("s")));
3974        assert!(dashboard.contains("20.0%"));
3975        assert!(dashboard.contains("80.0%"));
3976        assert!(dashboard.contains("Measured from summaries/slices"));
3977        assert!(dashboard.contains("Navigation narrowing"));
3978    }
3979
3980    #[test]
3981    fn overview_dashboard_bars_reflect_expected_ratios() {
3982        let full = block_bar(10, 1.0, THEME_BLUE);
3983        assert_bar_segments(&full, 10, 0, THEME_BLUE);
3984
3985        let partial = block_bar(10, 0.52, THEME_GREEN);
3986        assert_bar_segments(&partial, 5, 5, THEME_GREEN);
3987        assert_eq!(line_text(&partial), "█████░░░░░");
3988
3989        let clamped = block_bar(10, 2.0, THEME_YELLOW);
3990        assert_bar_segments(&clamped, 10, 0, THEME_YELLOW);
3991
3992        let empty = block_bar(10, -1.0, THEME_BLUE);
3993        assert_bar_segments(&empty, 0, 10, THEME_BLUE);
3994    }
3995
3996    #[test]
3997    fn atlas_preview_is_bounded_connected_and_centers_the_strongest_hub() {
3998        let kinds = [
3999            GraphRelationKind::Legacy(RelationKind::Imports),
4000            GraphRelationKind::Legacy(RelationKind::Calls),
4001            GraphRelationKind::Legacy(RelationKind::DependsOn),
4002        ];
4003        let mut relations = Vec::new();
4004        for branch in 0..12 {
4005            let branch_length = if branch < 11 { 4 } else { 3 };
4006            let root = format!("branch-{branch:02}-00");
4007            relations.push(("hub".to_string(), root.clone(), kinds[branch % kinds.len()]));
4008            let mut previous = root.clone();
4009            for depth in 1..branch_length {
4010                let node = format!("branch-{branch:02}-{depth:02}");
4011                relations.push((
4012                    previous,
4013                    node.clone(),
4014                    kinds[(branch + depth) % kinds.len()],
4015                ));
4016                previous = node;
4017            }
4018            relations.push((root, previous, kinds[(branch + 1) % kinds.len()]));
4019        }
4020        for branch in 0..5 {
4021            relations.push((
4022                format!("branch-{branch:02}-02"),
4023                format!("branch-{:02}-02", branch + 1),
4024                kinds[(branch + 2) % kinds.len()],
4025            ));
4026        }
4027        relations.extend([
4028            ("island-a".to_string(), "island-b".to_string(), kinds[0]),
4029            ("island-b".to_string(), "island-c".to_string(), kinds[1]),
4030        ]);
4031
4032        let atlas = TokenAtlasPreview::from_resolved_edges(relations, false);
4033        let layout = atlas_layout(&atlas.edges);
4034        let repeated_layout = atlas_layout(&atlas.edges);
4035
4036        assert!(atlas.available);
4037        assert!(atlas.truncated);
4038        assert_eq!(atlas.node_count(), ATLAS_PREVIEW_MAX_NODES);
4039        assert_eq!(atlas.edges.len(), ATLAS_PREVIEW_MAX_EDGES);
4040        assert_eq!(layout.hub, "hub");
4041        let Some(hub) = layout.nodes.get(&layout.hub) else {
4042            unreachable!("connected atlas should retain its hub placement");
4043        };
4044        assert_eq!((hub.x, hub.y), (0.0, 0.0));
4045        assert_eq!(layout.nodes, repeated_layout.nodes);
4046        assert!(
4047            atlas.edges.iter().all(
4048                |edge| !edge.source.starts_with("island") && !edge.target.starts_with("island")
4049            )
4050        );
4051        let mut selected_degrees = BTreeMap::<&str, usize>::new();
4052        let mut adjacency = BTreeMap::<&str, BTreeSet<&str>>::new();
4053        for edge in &atlas.edges {
4054            *selected_degrees.entry(&edge.source).or_default() += 1;
4055            *selected_degrees.entry(&edge.target).or_default() += 1;
4056            adjacency
4057                .entry(&edge.source)
4058                .or_default()
4059                .insert(&edge.target);
4060            adjacency
4061                .entry(&edge.target)
4062                .or_default()
4063                .insert(&edge.source);
4064        }
4065        assert!(
4066            selected_degrees
4067                .values()
4068                .all(|degree| *degree <= ATLAS_PREVIEW_MAX_NODE_DEGREE)
4069        );
4070        let mut visited = BTreeSet::from([layout.hub.as_str()]);
4071        let mut frontier = VecDeque::from([layout.hub.as_str()]);
4072        while let Some(node) = frontier.pop_front() {
4073            if let Some(neighbors) = adjacency.get(node) {
4074                for neighbor in neighbors {
4075                    if visited.insert(*neighbor) {
4076                        frontier.push_back(*neighbor);
4077                    }
4078                }
4079            }
4080        }
4081        assert_eq!(visited.len(), atlas.node_count());
4082        assert!(
4083            layout
4084                .nodes
4085                .values()
4086                .all(|node| node.x.abs() < ATLAS_CANVAS_X_BOUND
4087                    && node.y.abs() < ATLAS_CANVAS_Y_BOUND)
4088        );
4089    }
4090
4091    #[test]
4092    fn atlas_preview_discovers_expanding_branches_before_applying_visual_degree_limits() {
4093        let kind = GraphRelationKind::Legacy(RelationKind::Calls);
4094        let mut relations = Vec::new();
4095        for branch in 0..16 {
4096            let root = format!("branch-{branch:02}-root");
4097            relations.push(("hub".to_string(), root.clone(), kind));
4098            let leaves = (0..3)
4099                .map(|leaf| format!("branch-{branch:02}-leaf-{leaf}"))
4100                .collect::<Vec<_>>();
4101            for leaf in &leaves {
4102                relations.push((root.clone(), leaf.clone(), kind));
4103            }
4104            for (left, right) in [(0, 1), (1, 2), (2, 0)] {
4105                relations.push((leaves[left].clone(), leaves[right].clone(), kind));
4106            }
4107        }
4108
4109        let atlas = TokenAtlasPreview::from_resolved_edges(relations, false);
4110        assert_eq!(atlas.node_count(), ATLAS_PREVIEW_MAX_NODES);
4111        assert_eq!(atlas.edges.len(), ATLAS_PREVIEW_MAX_EDGES);
4112        assert!(atlas.truncated);
4113        let mut selected_degrees = BTreeMap::<&str, usize>::new();
4114        for edge in &atlas.edges {
4115            *selected_degrees.entry(&edge.source).or_default() += 1;
4116            *selected_degrees.entry(&edge.target).or_default() += 1;
4117        }
4118        assert!(
4119            selected_degrees
4120                .values()
4121                .all(|degree| *degree <= ATLAS_PREVIEW_MAX_NODE_DEGREE)
4122        );
4123
4124        let narrow =
4125            render_overview_buffer_with_atlas_at_width(&sample_overview(), None, &atlas, 189);
4126        assert!(!buffer_to_string(&narrow).contains(&reference_title("ATLAS MAP")));
4127        for width in [190, 200, 220] {
4128            let buffer =
4129                render_overview_buffer_with_atlas_at_width(&sample_overview(), None, &atlas, width);
4130            let dashboard = buffer_to_string(&buffer);
4131            assert!(dashboard.contains("48 nodes • 64 links"));
4132            let atlas_start = TOKEN_IMPACT_COLUMN_WIDTH + 2;
4133            let midpoint_x = atlas_start + (width - atlas_start) / 2;
4134            let midpoint_y = DASHBOARD_HEIGHT / 2;
4135            let quadrants = (0..buffer.area.height)
4136                .flat_map(|y| (atlas_start..buffer.area.width).map(move |x| (x, y)))
4137                .filter_map(|(x, y)| {
4138                    buffer.cell((x, y)).and_then(|cell| {
4139                        cell.symbol()
4140                            .chars()
4141                            .any(|character| ('\u{2801}'..='\u{28ff}').contains(&character))
4142                            .then_some((x >= midpoint_x, y >= midpoint_y))
4143                    })
4144                })
4145                .collect::<BTreeSet<_>>();
4146            assert!(
4147                quadrants.len() >= 3,
4148                "atlas should retain visible density across the panel at width {width}: {quadrants:?}"
4149            );
4150        }
4151    }
4152
4153    #[test]
4154    fn atlas_preview_excludes_containment_before_applying_bounds() {
4155        let mut relations = (0..80)
4156            .map(|index| {
4157                (
4158                    "containment-root".to_string(),
4159                    format!("contained-{index:02}"),
4160                    GraphRelationKind::Legacy(RelationKind::Contains),
4161                )
4162            })
4163            .collect::<Vec<_>>();
4164        relations.extend([
4165            (
4166                "network-root".to_string(),
4167                "network-a".to_string(),
4168                GraphRelationKind::Legacy(RelationKind::Calls),
4169            ),
4170            (
4171                "network-a".to_string(),
4172                "network-b".to_string(),
4173                GraphRelationKind::Legacy(RelationKind::Imports),
4174            ),
4175        ]);
4176
4177        let atlas = TokenAtlasPreview::from_resolved_edges(relations, false);
4178
4179        assert_eq!(atlas.edges.len(), 2);
4180        assert!(atlas.edges.iter().all(|edge| {
4181            !matches!(edge.kind, GraphRelationKind::Legacy(RelationKind::Contains))
4182                && !edge.source.starts_with("contain")
4183                && !edge.target.starts_with("contain")
4184        }));
4185    }
4186
4187    #[test]
4188    fn wide_atlas_map_renders_real_counts_and_stays_inside_its_panel() {
4189        let kind = GraphRelationKind::Legacy(RelationKind::Calls);
4190        let mut relations = (0..6)
4191            .map(|index| ("hub".to_string(), format!("node-{index}"), kind))
4192            .collect::<Vec<_>>();
4193        relations.extend([
4194            ("node-0".to_string(), "satellite-a".to_string(), kind),
4195            ("satellite-a".to_string(), "satellite-b".to_string(), kind),
4196        ]);
4197        let atlas = TokenAtlasPreview::from_resolved_edges(relations, false);
4198        let buffer =
4199            render_overview_buffer_with_atlas_at_width(&sample_overview(), Some("s"), &atlas, 200);
4200        let dashboard = buffer_to_string(&buffer);
4201
4202        assert!(dashboard.contains(&reference_title("ATLAS MAP")));
4203        assert!(dashboard.contains("9 nodes • 8 links"));
4204        assert!(dashboard.contains("bounded live graph • static snapshot"));
4205        assert!(buffer_contains_braille(
4206            &buffer,
4207            TOKEN_IMPACT_COLUMN_WIDTH + 2
4208        ));
4209        assert!(!dashboard.contains("Frozen v0.3.26"));
4210        assert!(!dashboard.contains("Plain Codex"));
4211        assert!(!dashboard.contains(&reference_title("REPEATED-WORK BENCHMARK")));
4212        for y in 2..(DASHBOARD_HEIGHT - 2) {
4213            assert_eq!(
4214                buffer.cell((198, y)).map(ratatui::buffer::Cell::symbol),
4215                Some("│"),
4216                "atlas panel right border should remain intact at row {y}"
4217            );
4218        }
4219    }
4220
4221    #[test]
4222    fn atlas_map_hides_when_narrow_and_never_invents_empty_state_data() {
4223        let kind = GraphRelationKind::Legacy(RelationKind::Calls);
4224        let atlas = TokenAtlasPreview::from_resolved_edges(
4225            [("source".to_string(), "target".to_string(), kind)],
4226            false,
4227        );
4228        let narrow =
4229            render_overview_buffer_with_atlas_at_width(&sample_overview(), Some("s"), &atlas, 189);
4230        assert!(!buffer_to_string(&narrow).contains(&reference_title("ATLAS MAP")));
4231
4232        for (atlas, message) in [
4233            (TokenAtlasPreview::empty(), "No resolved graph links"),
4234            (
4235                TokenAtlasPreview::unavailable(),
4236                "Graph preview unavailable",
4237            ),
4238        ] {
4239            let buffer = render_overview_buffer_with_atlas_at_width(
4240                &sample_overview(),
4241                Some("s"),
4242                &atlas,
4243                200,
4244            );
4245            let dashboard = buffer_to_string(&buffer);
4246            assert!(dashboard.contains(message));
4247            assert!(!dashboard.contains("nodes •"));
4248            assert!(!buffer_contains_braille(
4249                &buffer,
4250                TOKEN_IMPACT_COLUMN_WIDTH + 2
4251            ));
4252        }
4253    }
4254
4255    #[test]
4256    fn overview_dashboard_preserves_negative_savings_in_visual_widgets() {
4257        let overview = TokenOverview::from_events(&[
4258            usage_from_text(
4259                "s",
4260                "summary",
4261                Some("src/lib.rs".to_string()),
4262                None,
4263                "abcd",
4264                "abcdabcdabcd",
4265            ),
4266            usage_from_estimates("s", "search", None, Some("token".to_string()), 10, 30),
4267        ]);
4268        assert!(overview.measured_tokens_saved < 0);
4269        assert!(overview.deduped_modeled_tokens_avoided < 0);
4270
4271        let dashboard = strip_ansi(&render_token_dashboard(&overview, Some("s")));
4272        assert!(dashboard.contains(&format!(
4273            "Signed mix: observed {} / modeled {}; net {}",
4274            signed_count(overview.measured_tokens_saved),
4275            signed_count(overview.average_modeled_tokens_avoided),
4276            signed_count(overview.tokens_avoided)
4277        )));
4278        assert!(!dashboard.contains("% / modeled"));
4279        let wide_buffer = render_overview_buffer_at_width(&overview, Some("s"), 140);
4280        let Some((_, wide_title_y)) =
4281            find_text(&wide_buffer, &reference_title("AVERAGE TOKENS AVOIDED"))
4282        else {
4283            unreachable!("hero title should render");
4284        };
4285        let wide_hero_rows = ((wide_title_y + 1)
4286            ..=(wide_title_y + 4).min(wide_buffer.area.height.saturating_sub(1)))
4287            .map(|y| line_symbols(&wide_buffer, y))
4288            .collect::<Vec<_>>()
4289            .join("\n");
4290        assert!(
4291            wide_hero_rows.contains('!'),
4292            "wide negative hero should use a warning marker instead of a success check"
4293        );
4294        assert!(
4295            !wide_hero_rows.contains('✓'),
4296            "wide negative hero must not imply success with a check marker"
4297        );
4298
4299        let narrow_buffer = render_overview_buffer_at_width(&overview, Some("s"), 100);
4300        let Some((_, narrow_title_y)) =
4301            find_text(&narrow_buffer, &reference_title("AVERAGE TOKENS AVOIDED"))
4302        else {
4303            unreachable!("hero title should render");
4304        };
4305        let narrow_value_line = line_symbols(&narrow_buffer, narrow_title_y + 1);
4306        assert!(
4307            narrow_value_line.contains('!'),
4308            "narrow negative hero should use a warning marker instead of a success check"
4309        );
4310        assert!(
4311            !narrow_value_line.contains('✓'),
4312            "narrow negative hero must not imply success with a check marker"
4313        );
4314
4315        let trend = vec![
4316            TokenTrendPeriod::from_totals("loss".to_string(), 1, 10, 30),
4317            TokenTrendPeriod::from_totals("gain".to_string(), 1, 30, 10),
4318        ];
4319        let points = signed_trend_points(Some(&trend));
4320        assert_float_eq(points[0].1, -20.0);
4321        assert_float_eq(points[1].1, 20.0);
4322        let bounds = signed_y_bounds(&points);
4323        assert_float_eq(bounds[0], -20.0);
4324        assert_float_eq(bounds[1], 20.0);
4325
4326        let single = [TokenTrendPeriod::from_totals("one".to_string(), 1, 30, 10)];
4327        let single_points = signed_trend_points(Some(&single));
4328        assert_float_eq(single_points[0].0, 0.0);
4329        assert_float_eq(single_points[0].1, 20.0);
4330        assert_float_eq(single_points[1].0, 1.0);
4331        assert_float_eq(single_points[1].1, 20.0);
4332    }
4333
4334    #[test]
4335    fn overview_dashboard_distinguishes_negative_average_from_positive_maximum() {
4336        let mut folder =
4337            usage_from_estimates("s", "folders", Some("src".to_string()), None, 101, 60);
4338        folder.denominator_kind = TOKEN_BASELINE_DIRECTORY_WALK.to_string();
4339        let overview = TokenOverview::from_events(&[folder]);
4340        assert_eq!(overview.average_tokens_avoided, -10);
4341        assert_eq!(overview.maximum_tokens_avoided, 41);
4342
4343        for width in [80, 140] {
4344            let buffer = render_overview_buffer_at_width(&overview, Some("s"), width);
4345            let dashboard = buffer_to_string(&buffer);
4346            assert!(dashboard.contains(&reference_title("AVERAGE TOKENS AVOIDED")));
4347            assert!(dashboard.contains("Total Tokens Avoided"));
4348            assert!(dashboard.contains("Average avoided"));
4349            assert!(dashboard.contains("Maximum avoided"));
4350            assert_cell_style(&buffer, "-10", super::THEME_RED, Modifier::BOLD);
4351            assert_cell_style(&buffer, "41", THEME_YELLOW, Modifier::empty());
4352        }
4353
4354        let buffer = render_overview_buffer_at_width(&overview, Some("s"), 140);
4355        for (theme, average_color, maximum_color) in [
4356            (TokenDashboardTheme::Dark, super::THEME_RED, THEME_YELLOW),
4357            (
4358                TokenDashboardTheme::Light,
4359                super::LIGHT_THEME.red,
4360                super::LIGHT_THEME.yellow,
4361            ),
4362            (
4363                TokenDashboardTheme::Terminal,
4364                super::THEME_RED,
4365                THEME_YELLOW,
4366            ),
4367        ] {
4368            let dashboard = strip_ansi(&render_token_dashboard_with_theme(
4369                &overview,
4370                Some("s"),
4371                theme,
4372            ));
4373            assert!(dashboard.contains(&reference_title("AVERAGE TOKENS AVOIDED")));
4374            assert!(dashboard.contains("Total Tokens Avoided"));
4375            assert!(dashboard.contains("Average avoided"));
4376            assert!(dashboard.contains("Maximum avoided"));
4377            assert_themed_cell_style(&buffer, "-10", theme, average_color, Modifier::BOLD);
4378            assert_themed_cell_style(&buffer, "41", theme, maximum_color, Modifier::empty());
4379        }
4380    }
4381
4382    #[test]
4383    fn trend_dashboard_renders_chart_and_period_table() {
4384        let report = sample_trend_report();
4385        let dashboard = strip_ansi(&render_token_trend_dashboard(&report));
4386
4387        assert!(dashboard.contains("ProjectAtlas Token Trends"));
4388        assert!(dashboard.contains(&reference_title("SAVED TOKENS TREND")));
4389        assert!(dashboard.contains("2026-06"));
4390        assert!(dashboard.contains("2026-07"));
4391        assert!(dashboard.contains("period"));
4392        assert!(dashboard_contains_chart_glyph(&dashboard));
4393    }
4394
4395    fn sample_trend_report() -> TokenTrendReport {
4396        TokenTrendReport::new(
4397            Some("s".to_string()),
4398            TokenTrendWindow::Month,
4399            vec![
4400                TokenTrendPeriod::from_totals("2026-06".to_string(), 2, 200, 50),
4401                TokenTrendPeriod::from_totals("2026-07".to_string(), 1, 100, 80),
4402            ],
4403        )
4404    }
4405
4406    fn test_viewport(columns: u16, rows: u16) -> TokenDashboardViewport {
4407        resolve_dashboard_viewport(None, Some(columns), Some(rows))
4408    }
4409
4410    fn rendered_dashboard(result: std::io::Result<String>) -> String {
4411        match result {
4412            Ok(dashboard) => dashboard,
4413            Err(error) => unreachable!("in-memory token dashboard render failed: {error}"),
4414        }
4415    }
4416
4417    fn viewport_dimensions(viewport: TokenDashboardViewport) -> (u16, u16) {
4418        (viewport.columns(), viewport.rows())
4419    }
4420
4421    fn assert_ansi_bounds(output: &str, viewport: TokenDashboardViewport) {
4422        let plain = strip_ansi(output);
4423        assert!(
4424            plain.lines().count() <= usize::from(viewport.rows()),
4425            "dashboard exceeded {} rows:\n{plain}",
4426            viewport.rows()
4427        );
4428        for line in plain.lines() {
4429            assert!(
4430                line.cell_width() <= viewport.columns(),
4431                "dashboard line exceeded {} columns: {line:?}",
4432                viewport.columns()
4433            );
4434        }
4435    }
4436
4437    #[test]
4438    fn ansi_serializer_emits_each_wide_grapheme_once() {
4439        let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 4));
4440        buffer.set_string(0, 0, "A界B", Style::default());
4441        buffer.set_string(0, 1, "e\u{301}", Style::default());
4442        buffer.set_string(0, 2, "👨‍👩‍👧‍👦", Style::default());
4443
4444        let output = strip_ansi(&buffer_to_ansi_string(&buffer));
4445        let lines = output.lines().collect::<Vec<_>>();
4446        assert_eq!(lines[0].cell_width(), 8);
4447        assert_eq!(lines[0].matches('界').count(), 1);
4448        assert_eq!(lines[1].cell_width(), 8);
4449        assert_eq!(lines[2].cell_width(), 8);
4450    }
4451
4452    fn sample_overview() -> TokenOverview {
4453        TokenOverview::from_events(&[
4454            usage_from_text(
4455                "s",
4456                "summary",
4457                Some("src/lib.rs".to_string()),
4458                None,
4459                &"x".repeat(400),
4460                &"x".repeat(320),
4461            ),
4462            usage_from_estimates_with_accounting(
4463                "s",
4464                "folders",
4465                None,
4466                Some("src".to_string()),
4467                120,
4468                20,
4469                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4470                TOKEN_BASELINE_DIRECTORY_WALK,
4471                TOKEN_CONFIDENCE_POLICY_ESTIMATE,
4472                TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
4473                TOKEN_BASELINE_DIRECTORY_WALK,
4474                TOKEN_DEDUPE_SCOPE_SESSION,
4475            ),
4476            usage_from_estimates_with_accounting(
4477                "s",
4478                "search",
4479                None,
4480                Some("src".to_string()),
4481                80,
4482                16,
4483                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4484                TOKEN_BASELINE_DIRECTORY_WALK,
4485                TOKEN_CONFIDENCE_POLICY_ESTIMATE,
4486                TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
4487                TOKEN_BASELINE_SELECTED_CANDIDATES,
4488                TOKEN_DEDUPE_SCOPE_SESSION,
4489            ),
4490            usage_from_estimates_with_accounting(
4491                "s",
4492                "search",
4493                None,
4494                Some("token".to_string()),
4495                100,
4496                20,
4497                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4498                TOKEN_BASELINE_SELECTED_CANDIDATES,
4499                TOKEN_CONFIDENCE_INFERRED,
4500                TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
4501                TOKEN_BASELINE_SELECTED_CANDIDATES,
4502                TOKEN_DEDUPE_SCOPE_SESSION,
4503            ),
4504        ])
4505    }
4506
4507    fn render_overview_buffer(overview: &TokenOverview, session: Option<&str>) -> Buffer {
4508        render_overview_buffer_at_width(overview, session, 140)
4509    }
4510
4511    fn render_compact_lines_buffer(lines: Vec<Line<'_>>, width: u16, height: u16) -> Buffer {
4512        let backend = TestBackend::new(width, height);
4513        let mut terminal =
4514            Terminal::new(backend).expect("in-memory token dashboard backend should initialize");
4515        let frame = terminal
4516            .draw(move |frame| {
4517                frame.render_widget(ratatui::widgets::Paragraph::new(lines), frame.area());
4518            })
4519            .expect("in-memory compact token dashboard should render");
4520        frame.buffer.clone()
4521    }
4522
4523    fn render_overview_buffer_at_width(
4524        overview: &TokenOverview,
4525        session: Option<&str>,
4526        width: u16,
4527    ) -> Buffer {
4528        let backend = TestBackend::new(width, DASHBOARD_HEIGHT);
4529        let mut terminal =
4530            Terminal::new(backend).expect("in-memory token dashboard backend should initialize");
4531        let frame = terminal
4532            .draw(|frame| render_overview_frame(frame, overview, session))
4533            .expect("in-memory token dashboard should render");
4534        frame.buffer.clone()
4535    }
4536
4537    fn render_overview_buffer_with_atlas_at_width(
4538        overview: &TokenOverview,
4539        session: Option<&str>,
4540        atlas: &TokenAtlasPreview,
4541        width: u16,
4542    ) -> Buffer {
4543        let backend = TestBackend::new(width, DASHBOARD_HEIGHT);
4544        let mut terminal =
4545            Terminal::new(backend).expect("in-memory token dashboard backend should initialize");
4546        let frame = terminal
4547            .draw(|frame| render_overview_frame_with_atlas(frame, overview, session, Some(atlas)))
4548            .expect("in-memory token dashboard with atlas should render");
4549        frame.buffer.clone()
4550    }
4551
4552    fn buffer_contains_braille(buffer: &Buffer, start_x: u16) -> bool {
4553        (0..buffer.area.height).any(|y| {
4554            (start_x..buffer.area.width).any(|x| {
4555                buffer.cell((x, y)).is_some_and(|cell| {
4556                    cell.symbol()
4557                        .chars()
4558                        .any(|character| ('\u{2801}'..='\u{28ff}').contains(&character))
4559                })
4560            })
4561        })
4562    }
4563
4564    fn render_trend_buffer(report: &TokenTrendReport) -> Buffer {
4565        let backend = TestBackend::new(
4566            super::DASHBOARD_DEFAULT_WIDTH,
4567            super::TREND_DASHBOARD_HEIGHT,
4568        );
4569        let mut terminal =
4570            Terminal::new(backend).expect("in-memory token dashboard backend should initialize");
4571        let frame = terminal
4572            .draw(|frame| super::render_trend_frame(frame, report))
4573            .expect("in-memory token dashboard should render");
4574        frame.buffer.clone()
4575    }
4576
4577    fn line_symbols(buffer: &Buffer, y: u16) -> String {
4578        let mut line = String::new();
4579        for x in 0..buffer.area.width {
4580            if let Some(cell) = buffer.cell((x, y)) {
4581                line.push_str(cell.symbol());
4582            }
4583        }
4584        line
4585    }
4586
4587    fn assert_no_terminal_canvas_fill(buffer: &Buffer) {
4588        for y in 0..buffer.area.height {
4589            for x in 0..buffer.area.width {
4590                let Some(cell) = buffer.cell((x, y)) else {
4591                    continue;
4592                };
4593                assert_ne!(
4594                    cell.bg, THEME_BG,
4595                    "dashboard should not force the terminal canvas background at ({x},{y})"
4596                );
4597            }
4598        }
4599    }
4600
4601    fn assert_header_margin(dashboard: &str, header: &str, first_row: &str) {
4602        let header_index = dashboard.lines().position(|line| line.contains(header));
4603        assert!(
4604            header_index.is_some(),
4605            "dashboard should contain table header {header:?}"
4606        );
4607        let Some(header_index) = header_index else {
4608            return;
4609        };
4610        let row_index = dashboard.lines().position(|line| line.contains(first_row));
4611        assert!(
4612            row_index.is_some(),
4613            "dashboard should contain first table row {first_row:?}"
4614        );
4615        let Some(row_index) = row_index else {
4616            return;
4617        };
4618        assert!(
4619            row_index >= header_index + 2,
4620            "expected a visible separator row between {header:?} and {first_row:?}"
4621        );
4622    }
4623
4624    fn assert_in_order(dashboard: &str, needles: &[&str]) {
4625        let mut previous = 0usize;
4626        for needle in needles {
4627            let Some(index) = dashboard.find(needle) else {
4628                assert!(
4629                    dashboard.contains(needle),
4630                    "dashboard should contain {needle:?}"
4631                );
4632                return;
4633            };
4634            assert!(
4635                index >= previous,
4636                "{needle:?} should appear after the previous section"
4637            );
4638            previous = index;
4639        }
4640    }
4641
4642    fn dashboard_contains_time(dashboard: &str) -> bool {
4643        let bytes = dashboard.as_bytes();
4644        bytes.windows(8).any(|window| {
4645            window.len() == 8
4646                && window[2] == b':'
4647                && window[5] == b':'
4648                && window
4649                    .iter()
4650                    .enumerate()
4651                    .all(|(index, byte)| index == 2 || index == 5 || byte.is_ascii_digit())
4652        })
4653    }
4654
4655    fn normalize_dashboard_clock(mut dashboard: String) -> String {
4656        let Some(time_start) = dashboard
4657            .find("Snapshot ")
4658            .map(|start| start + "Snapshot ".len())
4659        else {
4660            return dashboard;
4661        };
4662        let time_len = if dashboard.as_bytes().get(time_start + 5) == Some(&b':') {
4663            8
4664        } else {
4665            5
4666        };
4667        dashboard.replace_range(time_start..time_start + time_len, "CLOCK");
4668        dashboard
4669    }
4670
4671    fn assert_bar_segments(line: &Line<'_>, filled: usize, empty: usize, color: Color) {
4672        assert_eq!(line.spans.len(), 2);
4673        assert_eq!(line.spans[0].content.chars().count(), filled);
4674        assert_eq!(line.spans[1].content.chars().count(), empty);
4675        assert!(
4676            line.spans[0]
4677                .content
4678                .chars()
4679                .all(|character| character == '█')
4680        );
4681        assert!(
4682            line.spans[1]
4683                .content
4684                .chars()
4685                .all(|character| character == '░')
4686        );
4687        assert_eq!(line.spans[0].style.fg, Some(color));
4688        assert_eq!(line.spans[1].style.fg, Some(THEME_BAR_EMPTY));
4689    }
4690
4691    fn line_text(line: &Line<'_>) -> String {
4692        line.spans
4693            .iter()
4694            .map(|span| span.content.as_ref())
4695            .collect::<String>()
4696    }
4697
4698    fn dashboard_contains_chart_glyph(dashboard: &str) -> bool {
4699        dashboard.chars().any(|character| {
4700            matches!(
4701                character,
4702                '█' | '▌' | '▏' | '▅' | '▁' | '\u{2801}'..='\u{28ff}'
4703            )
4704        })
4705    }
4706
4707    fn assert_float_eq(left: f64, right: f64) {
4708        assert!(
4709            (left - right).abs() < f64::EPSILON,
4710            "expected {left} to equal {right}"
4711        );
4712    }
4713
4714    fn assert_cell_style(buffer: &Buffer, text: &str, color: Color, modifier: Modifier) {
4715        let found = find_text(buffer, text);
4716        assert!(found.is_some(), "rendered buffer should contain {text:?}");
4717        let Some((x, y)) = found else {
4718            return;
4719        };
4720        let cell = buffer.cell((x, y));
4721        assert!(
4722            cell.is_some(),
4723            "located text should resolve to a buffer cell"
4724        );
4725        let Some(cell) = cell else {
4726            return;
4727        };
4728        assert_eq!(cell.fg, color, "unexpected foreground color for {text:?}");
4729        assert!(
4730            cell.modifier.contains(modifier),
4731            "missing modifier {modifier:?} for {text:?}"
4732        );
4733    }
4734
4735    fn assert_themed_cell_style(
4736        buffer: &Buffer,
4737        text: &str,
4738        theme: TokenDashboardTheme,
4739        color: Color,
4740        modifier: Modifier,
4741    ) {
4742        super::with_token_theme(theme, || {
4743            let Some((x, y)) = find_text(buffer, text) else {
4744                unreachable!("rendered buffer should contain {text:?}");
4745            };
4746            let Some(cell) = buffer.cell((x, y)) else {
4747                unreachable!("located text should resolve to a buffer cell");
4748            };
4749            let style = super::CellAnsiStyle::from_cell(cell);
4750            assert_eq!(style.fg, color, "unexpected themed color for {text:?}");
4751            assert!(
4752                style.modifier.contains(modifier),
4753                "missing themed modifier {modifier:?} for {text:?}"
4754            );
4755        });
4756    }
4757
4758    fn strip_ansi(input: &str) -> String {
4759        let mut output = String::with_capacity(input.len());
4760        let mut chars = input.chars().peekable();
4761        while let Some(character) = chars.next() {
4762            if character == '\u{1b}' && chars.peek() == Some(&'[') {
4763                chars.next();
4764                for code in chars.by_ref() {
4765                    if code.is_ascii_alphabetic() {
4766                        break;
4767                    }
4768                }
4769            } else {
4770                output.push(character);
4771            }
4772        }
4773        output
4774    }
4775
4776    fn find_text(buffer: &Buffer, text: &str) -> Option<(u16, u16)> {
4777        assert!(
4778            text.is_ascii(),
4779            "use direct cell assertions for non-ASCII symbols"
4780        );
4781        for y in 0..buffer.area.height {
4782            let mut cells = Vec::new();
4783            let mut line = String::new();
4784            for x in 0..buffer.area.width {
4785                let symbol = buffer.cell((x, y))?.symbol();
4786                if symbol.is_ascii() {
4787                    line.push_str(symbol);
4788                } else {
4789                    line.push(' ');
4790                }
4791                cells.push((x, y));
4792            }
4793            if let Some(index) = line.find(text) {
4794                return cells.get(index).copied();
4795            }
4796        }
4797        None
4798    }
4799}