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