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