1use 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
23const DASHBOARD_HEIGHT: u16 = 50;
25const ATLAS_DASHBOARD_MIN_WIDTH: usize = 190;
27const DASHBOARD_MAX_WIDTH: usize = 200;
29const TOKEN_IMPACT_COLUMN_WIDTH: u16 = 140;
31const ATLAS_PREVIEW_MAX_NODES: usize = 48;
33const ATLAS_PREVIEW_MAX_EDGES: usize = 64;
35const ATLAS_PREVIEW_MAX_NODE_DEGREE: usize = 12;
37const ATLAS_CANVAS_X_BOUND: f64 = 33.0;
39const ATLAS_CANVAS_Y_BOUND: f64 = 21.0;
41const ATLAS_LAYOUT_ITERATIONS: usize = 120;
43const ATLAS_LAYOUT_IDEAL_DISTANCE: f64 = 18.0;
45const ATLAS_LAYOUT_INITIAL_TEMPERATURE: f64 = 8.0;
47const ATLAS_NODE_HALO_DEGREE: usize = 4;
49const TREND_DASHBOARD_HEIGHT: u16 = 30;
51const THEME_BG: Color = Color::Rgb(4, 10, 18);
53const THEME_PANEL: Color = Color::Rgb(5, 16, 25);
55const THEME_TEXT: Color = Color::Rgb(224, 198, 164);
57const THEME_MUTED: Color = Color::Rgb(170, 143, 116);
59const THEME_INK_WHITE: Color = Color::Rgb(238, 234, 224);
61const THEME_BLUE: Color = Color::Rgb(93, 143, 255);
63const THEME_GREEN: Color = Color::Rgb(111, 216, 100);
65const THEME_YELLOW: Color = Color::Rgb(230, 179, 55);
67const THEME_BORDER: Color = Color::Rgb(92, 74, 55);
69const THEME_BAR_EMPTY: Color = Color::Rgb(49, 56, 57);
71const THEME_RED: Color = Color::Rgb(235, 95, 95);
73const THEME_PURPLE: Color = Color::Rgb(173, 127, 255);
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub(crate) enum TokenDashboardTheme {
78 Dark,
80 Light,
82 Terminal,
84}
85
86impl TokenDashboardTheme {
87 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#[derive(Clone, Copy)]
100struct ThemePalette {
101 bg: Color,
103 panel: Color,
105 text: Color,
107 muted: Color,
109 ink_white: Color,
111 blue: Color,
113 green: Color,
115 yellow: Color,
117 border: Color,
119 bar_empty: Color,
121 red: Color,
123 purple: Color,
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
129struct AtlasPreviewEdge {
130 source: String,
132 target: String,
134 kind: GraphRelationKind,
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
140pub(crate) struct TokenAtlasPreview {
141 edges: Vec<AtlasPreviewEdge>,
143 truncated: bool,
145 available: bool,
147}
148
149pub(crate) const fn token_atlas_network_relation(kind: GraphRelationKind) -> bool {
151 !matches!(kind, GraphRelationKind::Legacy(RelationKind::Contains))
152}
153
154impl TokenAtlasPreview {
155 #[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 #[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 #[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 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(°rees.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 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
335const 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 static ACTIVE_TOKEN_THEME: StdCell<TokenDashboardTheme> = const { StdCell::new(TokenDashboardTheme::Dark) };
354}
355
356#[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#[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
377pub(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#[must_use]
394pub(crate) fn token_dashboard_wants_atlas() -> bool {
395 dashboard_width() >= ATLAS_DASHBOARD_MIN_WIDTH
396}
397
398pub(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#[cfg(test)]
414pub(crate) fn render_token_trend_dashboard(report: &TokenTrendReport) -> String {
415 render_token_trend_dashboard_with_theme(report, TokenDashboardTheme::Dark)
416}
417
418pub(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
431pub(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
444fn 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
454fn active_token_theme() -> TokenDashboardTheme {
456 ACTIVE_TOKEN_THEME.with(StdCell::get)
457}
458
459fn 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
473fn 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
487fn render_overview_frame(frame: &mut Frame<'_>, overview: &TokenOverview, session: Option<&str>) {
489 render_overview_frame_with_atlas(frame, overview, session, None);
490}
491
492fn 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
526fn 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
555fn 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
571fn 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
606fn 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
665fn 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
741fn 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
763fn 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
772fn 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
802fn 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
812fn 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
831fn 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
949fn 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
990fn 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
1009fn 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
1044fn 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
1058fn 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
1141fn 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
1168fn 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
1205fn 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
1263fn 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(¢er, 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
1366fn 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#[derive(Clone, Copy, Debug, PartialEq)]
1398struct AtlasNodePlacement {
1399 x: f64,
1401 y: f64,
1403 degree: usize,
1405 distance: usize,
1407 cluster: usize,
1409}
1410
1411struct AtlasLayout {
1413 nodes: BTreeMap<String, AtlasNodePlacement>,
1415 hub: String,
1417}
1418
1419fn 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
1545fn 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
1592const 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
1602fn 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
1638fn 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
1675fn 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
1684fn 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
1705fn 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#[derive(Clone, Copy)]
1723struct TokenMix {
1724 observed: isize,
1726 modeled: isize,
1728 observed_abs: usize,
1730 modeled_abs: usize,
1732}
1733
1734impl TokenMix {
1735 fn net(self) -> isize {
1737 self.observed.saturating_add(self.modeled)
1738 }
1739
1740 fn total_abs(self) -> usize {
1742 self.observed_abs.saturating_add(self.modeled_abs)
1743 }
1744}
1745
1746fn 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
1756struct SavingsSourceRow {
1758 label: &'static str,
1760 steps: usize,
1762 tokens: isize,
1764 meaning: &'static str,
1766 icon: &'static str,
1768 color: Color,
1770}
1771
1772fn 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
1860struct ModeledSourceGroup {
1862 label: &'static str,
1864 compact_label: &'static str,
1866 meaning: &'static str,
1868 compact_meaning: &'static str,
1870 icon: &'static str,
1872 steps: usize,
1874 gross_tokens: isize,
1876}
1877
1878impl ModeledSourceGroup {
1879 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 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
1905fn 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
1920fn 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
1966fn 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
1979fn 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
1986fn 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
2012fn 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
2026fn reconciled_without_projectatlas(overview: &TokenOverview) -> isize {
2028 usize_to_isize_saturating(overview.estimated_with_projectatlas)
2029 .saturating_add(overview.tokens_avoided)
2030}
2031
2032fn usize_to_isize_saturating(value: usize) -> isize {
2034 isize::try_from(value).unwrap_or(isize::MAX)
2035}
2036
2037fn signed_color(value: isize) -> Color {
2039 if value >= 0 { THEME_GREEN } else { THEME_RED }
2040}
2041
2042fn 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
2050fn header_style() -> Style {
2052 section_title_style()
2053}
2054
2055fn section_title_style() -> Style {
2057 Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD)
2058}
2059
2060fn 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
2081fn identity_style() -> Style {
2083 Style::default()
2084 .fg(THEME_INK_WHITE)
2085 .add_modifier(Modifier::BOLD)
2086}
2087
2088fn identity_title_style() -> Style {
2090 Style::default()
2091 .fg(THEME_INK_WHITE)
2092 .add_modifier(Modifier::BOLD)
2093}
2094
2095fn token_title_style() -> Style {
2097 Style::default().fg(THEME_BLUE).add_modifier(Modifier::BOLD)
2098}
2099
2100fn body_style() -> Style {
2102 Style::default().fg(THEME_TEXT)
2103}
2104
2105fn muted_style() -> Style {
2107 Style::default().fg(THEME_MUTED)
2108}
2109
2110fn muted_bold_style() -> Style {
2112 muted_style().add_modifier(Modifier::BOLD)
2113}
2114
2115fn percentage_one_decimal(value: f64) -> String {
2117 format!("{:.1}%", value.clamp(0.0, 1.0) * 100.0)
2118}
2119
2120fn 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
2132fn 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
2150fn 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
2167fn 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
2178fn 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
2245fn 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
2295fn 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
2317fn 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2344struct CellAnsiStyle {
2345 fg: Color,
2347 bg: Color,
2349 modifier: Modifier,
2351}
2352
2353impl CellAnsiStyle {
2354 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 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
2389fn 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
2421fn 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
2433fn 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
2452fn label(text: &str) -> Span<'static> {
2454 Span::styled(format!("{text}: "), muted_bold_style())
2455}
2456
2457fn value(value: usize) -> Span<'static> {
2459 Span::styled(grouped_count(value), identity_style())
2460}
2461
2462fn 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
2470fn 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
2479fn 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
2488fn 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
2499fn 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
2506fn 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
2519fn 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}