use super::{
DailyRow, ModelBreakdown, MonthlyRow, NumberFormat, ReportOutput, SessionRow, Totals,
UsageTotals, WatchSnapshot, scale_cost_per_hour, scale_usage_per_hour,
};
use std::collections::BTreeMap;
use std::env;
use std::fmt::Write as _;
use std::io::IsTerminal;
use terminal_size::{Width, terminal_size};
pub(super) fn render_report(
report: &ReportOutput,
locale: &str,
number_format: NumberFormat,
) -> String {
let mut output = match report {
ReportOutput::Daily { rows, totals, .. } => {
render_daily_report(rows, totals, locale, number_format)
}
ReportOutput::Monthly { rows, totals, .. } => {
render_monthly_report(rows, totals, locale, number_format)
}
ReportOutput::Session { rows, totals, .. } => {
render_session_report(rows, totals, locale, number_format)
}
};
let missing_directories = match report {
ReportOutput::Daily {
missing_directories,
..
}
| ReportOutput::Monthly {
missing_directories,
..
}
| ReportOutput::Session {
missing_directories,
..
} => missing_directories,
};
if !missing_directories.is_empty() {
let mut warning = String::from("Warning: missing session directories\n");
for directory in missing_directories {
let _ = writeln!(&mut warning, "- {directory}");
}
warning.push('\n');
warning.push_str(&output);
output = warning;
}
output
}
pub(super) fn render_watch_screen(
snapshot: &WatchSnapshot,
locale: &str,
number_format: NumberFormat,
show_model_burn_rate: bool,
) -> String {
render_watch_screen_with_width(
snapshot,
locale,
number_format,
show_model_burn_rate,
detect_terminal_width(),
)
}
pub(super) fn render_watch_screen_with_width(
snapshot: &WatchSnapshot,
_locale: &str,
number_format: NumberFormat,
show_model_burn_rate: bool,
terminal_width: Option<usize>,
) -> String {
let render_config = TableRenderConfig {
style: detect_table_style(),
borders: detect_border_style(),
number_format,
};
let mut output = String::new();
let _ = writeln!(
&mut output,
"{}",
paint(
render_config.style,
TableElement::Title,
"Current Day Codex Usage Watch"
)
);
let _ = writeln!(
&mut output,
"Date: {} Window: {} minutes",
snapshot.date, snapshot.burn_rate.window_minutes
);
let _ = writeln!(&mut output);
write_watch_table(
&mut output,
render_config,
snapshot,
number_format,
show_model_burn_rate,
terminal_width,
);
if !snapshot.missing_directories.is_empty() {
let mut warning = String::from("Warning: missing session directories\n");
for directory in &snapshot.missing_directories {
let _ = writeln!(&mut warning, "- {directory}");
}
warning.push('\n');
warning.push_str(&output);
return warning;
}
output
}
fn detect_terminal_width() -> Option<usize> {
terminal_size().map(|(Width(width), _height)| usize::from(width))
}
fn write_watch_table(
output: &mut String,
render_config: TableRenderConfig,
snapshot: &WatchSnapshot,
number_format: NumberFormat,
show_model_burn_rate: bool,
terminal_width: Option<usize>,
) {
let model_columns = if show_model_burn_rate {
active_watch_burn_columns(snapshot)
} else {
Vec::new()
};
let model_headers = model_columns
.iter()
.map(|column| column.label.clone())
.collect::<Vec<_>>();
let rows = watch_rows(snapshot, number_format, &model_columns);
let blocks = watch_table_blocks(
&model_headers,
&rows,
snapshot,
render_config,
terminal_width,
);
for (index, block) in blocks.iter().enumerate() {
if index > 0 {
output.push('\n');
}
let headers = watch_block_headers(&model_headers, block);
let header_refs = headers.iter().map(String::as_str).collect::<Vec<_>>();
let block_rows = watch_block_rows(&rows, block);
let updated_row = watch_updated_row(snapshot, block);
let widths = column_widths(&header_refs, &block_rows, &updated_row, number_format);
write_table_header(output, render_config, &header_refs, &widths);
for row in &block_rows {
write_table_row(
output,
render_config,
&header_refs,
&widths,
&row.cells,
row_table_element(row.kind),
);
}
write_table_row(
output,
render_config,
&header_refs,
&widths,
&updated_row.cells,
row_table_element(updated_row.kind),
);
write_table_rule(
output,
render_config.style,
table_rule_element(TableRuleKind::Bottom),
&table_rule(TableRuleKind::Bottom, render_config.borders, &widths),
);
}
}
fn render_daily_report(
rows: &[DailyRow],
totals: &Totals,
locale: &str,
number_format: NumberFormat,
) -> String {
let render_config = TableRenderConfig {
style: detect_table_style(),
borders: detect_border_style(),
number_format,
};
render_usage_table(
"Daily",
render_config,
locale,
&[
"Date",
"Model",
"Input",
"Cache",
"Output",
"Reasoning",
"Total",
"Cost",
],
daily_display_rows(rows),
totals,
)
}
fn render_monthly_report(
rows: &[MonthlyRow],
totals: &Totals,
locale: &str,
number_format: NumberFormat,
) -> String {
let render_config = TableRenderConfig {
style: detect_table_style(),
borders: detect_border_style(),
number_format,
};
render_usage_table(
"Monthly",
render_config,
locale,
&[
"Month",
"Model",
"Input",
"Cache",
"Output",
"Reasoning",
"Total",
"Cost",
],
monthly_display_rows(rows),
totals,
)
}
fn render_session_report(
rows: &[SessionRow],
totals: &Totals,
locale: &str,
number_format: NumberFormat,
) -> String {
let render_config = TableRenderConfig {
style: detect_table_style(),
borders: detect_border_style(),
number_format,
};
render_usage_table(
"Session",
render_config,
locale,
&[
"Directory",
"Session",
"Model",
"Input",
"Cache",
"Output",
"Reasoning",
"Total",
"Cost",
"Last Activity",
],
session_display_rows(rows),
totals,
)
}
#[derive(Clone, Debug)]
struct DisplayRow {
cells: Vec<String>,
kind: DisplayRowKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum DisplayRowKind {
Spacer,
Subtotal,
Detail,
GrandTotal,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum TableStyle {
Plain,
Ansi256,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum BorderStyle {
Ascii,
Unicode,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct TableRenderConfig {
pub(super) style: TableStyle,
pub(super) borders: BorderStyle,
pub(super) number_format: NumberFormat,
}
fn detect_table_style() -> TableStyle {
detect_table_style_for(
std::io::stdout().is_terminal(),
env::var("TERM").ok().as_deref(),
env::var("COLORTERM").ok().as_deref(),
env::var_os("NO_COLOR").is_some(),
)
}
fn detect_border_style() -> BorderStyle {
detect_border_style_for(
std::io::stdout().is_terminal(),
env::var("LC_ALL").ok().as_deref(),
env::var("LC_CTYPE").ok().as_deref(),
env::var("LANG").ok().as_deref(),
)
}
pub(super) fn detect_border_style_for(
stdout_is_terminal: bool,
lc_all: Option<&str>,
lc_ctype: Option<&str>,
lang: Option<&str>,
) -> BorderStyle {
if !stdout_is_terminal {
return BorderStyle::Ascii;
}
let locale = lc_all
.filter(|value| !value.is_empty())
.or(lc_ctype.filter(|value| !value.is_empty()))
.or(lang.filter(|value| !value.is_empty()))
.unwrap_or_default()
.to_ascii_lowercase();
if locale.contains("utf-8") || locale.contains("utf8") {
BorderStyle::Unicode
} else {
BorderStyle::Ascii
}
}
pub(super) fn detect_table_style_for(
stdout_is_terminal: bool,
term: Option<&str>,
colorterm: Option<&str>,
no_color: bool,
) -> TableStyle {
if no_color || !stdout_is_terminal {
return TableStyle::Plain;
}
let term = term.unwrap_or_default();
if term.is_empty() || term == "dumb" {
return TableStyle::Plain;
}
let colorterm = colorterm.unwrap_or_default();
if term.contains("256color")
|| colorterm.eq_ignore_ascii_case("truecolor")
|| colorterm.eq_ignore_ascii_case("24bit")
{
TableStyle::Ansi256
} else {
TableStyle::Plain
}
}
fn daily_display_rows(rows: &[DailyRow]) -> Vec<DisplayRow> {
let mut display_rows = Vec::new();
for (index, row) in rows.iter().enumerate() {
if index > 0 {
display_rows.push(DisplayRow {
cells: Vec::new(),
kind: DisplayRowKind::Spacer,
});
}
display_rows.push(DisplayRow {
cells: vec![
row.date.clone(),
"TOTAL".to_string(),
row.input_tokens.to_string(),
row.cached_input_tokens.to_string(),
row.output_tokens.to_string(),
row.reasoning_output_tokens.to_string(),
row.total_tokens.to_string(),
format_currency(row.cost_usd),
],
kind: DisplayRowKind::Subtotal,
});
append_model_display_rows(&mut display_rows, 1, false, &row.models);
}
display_rows
}
fn monthly_display_rows(rows: &[MonthlyRow]) -> Vec<DisplayRow> {
let mut display_rows = Vec::new();
for (index, row) in rows.iter().enumerate() {
if index > 0 {
display_rows.push(DisplayRow {
cells: Vec::new(),
kind: DisplayRowKind::Spacer,
});
}
display_rows.push(DisplayRow {
cells: vec![
row.month.clone(),
"TOTAL".to_string(),
row.input_tokens.to_string(),
row.cached_input_tokens.to_string(),
row.output_tokens.to_string(),
row.reasoning_output_tokens.to_string(),
row.total_tokens.to_string(),
format_currency(row.cost_usd),
],
kind: DisplayRowKind::Subtotal,
});
append_model_display_rows(&mut display_rows, 1, false, &row.models);
}
display_rows
}
fn session_display_rows(rows: &[SessionRow]) -> Vec<DisplayRow> {
let mut display_rows = Vec::new();
for (index, row) in rows.iter().enumerate() {
if index > 0 {
display_rows.push(DisplayRow {
cells: Vec::new(),
kind: DisplayRowKind::Spacer,
});
}
display_rows.push(DisplayRow {
cells: vec![
if row.directory.is_empty() {
"-".to_string()
} else {
row.directory.clone()
},
row.session_file.clone(),
"TOTAL".to_string(),
row.input_tokens.to_string(),
row.cached_input_tokens.to_string(),
row.output_tokens.to_string(),
row.reasoning_output_tokens.to_string(),
row.total_tokens.to_string(),
format_currency(row.cost_usd),
row.last_activity.clone(),
],
kind: DisplayRowKind::Subtotal,
});
append_model_display_rows(&mut display_rows, 2, true, &row.models);
}
display_rows
}
fn append_model_display_rows(
display_rows: &mut Vec<DisplayRow>,
columns_before_model: usize,
include_last_activity_column: bool,
models: &BTreeMap<String, ModelBreakdown>,
) {
for (model, breakdown) in models {
let explicit_usage = explicit_usage(breakdown);
if explicit_usage.has_usage() {
display_rows.push(model_display_row(
columns_before_model,
include_last_activity_column,
model,
&explicit_usage,
breakdown.cost_usd,
));
}
if breakdown.fallback_usage.has_usage() {
display_rows.push(model_display_row(
columns_before_model,
include_last_activity_column,
&format!("{model} (fallback)"),
&breakdown.fallback_usage,
breakdown.fallback_cost_usd,
));
}
}
}
fn model_display_row(
columns_before_model: usize,
include_last_activity_column: bool,
model_label: &str,
usage: &UsageTotals,
cost_usd: f64,
) -> DisplayRow {
let mut cells = vec![String::new(); columns_before_model];
cells.push(format!(" {model_label}"));
cells.extend_from_slice(&[
usage.input.to_string(),
usage.cached_input.to_string(),
usage.output.to_string(),
usage.reasoning_output.to_string(),
usage.total.to_string(),
format_currency(cost_usd),
]);
if include_last_activity_column {
cells.push(String::new());
}
DisplayRow {
cells,
kind: DisplayRowKind::Detail,
}
}
struct WatchBurnColumn {
label: String,
usage: UsageTotals,
cost_usd: f64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct WatchMetricRow {
metric: &'static str,
today: String,
per_model: Vec<String>,
burn_rate: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct WatchTableBlock {
include_today: bool,
model_start: usize,
model_end: usize,
include_burn_rate: bool,
}
impl WatchTableBlock {
const fn new(
include_today: bool,
model_start: usize,
model_end: usize,
include_burn_rate: bool,
) -> Self {
Self {
include_today,
model_start,
model_end,
include_burn_rate,
}
}
const fn model_count(self) -> usize {
self.model_end.saturating_sub(self.model_start)
}
}
fn active_watch_burn_columns(snapshot: &WatchSnapshot) -> Vec<WatchBurnColumn> {
let mut columns = Vec::new();
for (model, breakdown) in &snapshot.per_model {
let explicit = explicit_usage(breakdown);
if explicit.has_usage() || breakdown.cost_usd > 0.0 {
columns.push(WatchBurnColumn {
label: format!("{model} /h"),
usage: explicit,
cost_usd: breakdown.cost_usd,
});
}
if breakdown.fallback_usage.has_usage() || breakdown.fallback_cost_usd > 0.0 {
columns.push(WatchBurnColumn {
label: format!("{model} (fallback) /h"),
usage: breakdown.fallback_usage.clone(),
cost_usd: breakdown.fallback_cost_usd,
});
}
}
columns
}
fn watch_rows(
snapshot: &WatchSnapshot,
number_format: NumberFormat,
model_columns: &[WatchBurnColumn],
) -> Vec<WatchMetricRow> {
let token_cells = |select: fn(&UsageTotals) -> u64| {
model_columns
.iter()
.map(|column| {
scale_usage_per_hour(select(&column.usage), snapshot.burn_rate.window_duration)
})
.map(|value| format_u64_with(value, number_format))
.collect::<Vec<_>>()
};
let cost_cells = model_columns
.iter()
.map(|column| {
format_currency(scale_cost_per_hour(
column.cost_usd,
snapshot.burn_rate.window_duration,
))
})
.collect::<Vec<_>>();
vec![
WatchMetricRow {
metric: "Input",
today: format_u64_with(snapshot.totals.input_tokens, number_format),
per_model: token_cells(|usage| usage.input),
burn_rate: format_u64_with(snapshot.burn_rate.input_tokens_per_hour, number_format),
},
WatchMetricRow {
metric: "Cache",
today: format_u64_with(snapshot.totals.cached_input_tokens, number_format),
per_model: token_cells(|usage| usage.cached_input),
burn_rate: format_u64_with(
snapshot.burn_rate.cached_input_tokens_per_hour,
number_format,
),
},
WatchMetricRow {
metric: "Output",
today: format_u64_with(snapshot.totals.output_tokens, number_format),
per_model: token_cells(|usage| usage.output),
burn_rate: format_u64_with(snapshot.burn_rate.output_tokens_per_hour, number_format),
},
WatchMetricRow {
metric: "Reasoning",
today: format_u64_with(snapshot.totals.reasoning_output_tokens, number_format),
per_model: token_cells(|usage| usage.reasoning_output),
burn_rate: format_u64_with(
snapshot.burn_rate.reasoning_output_tokens_per_hour,
number_format,
),
},
WatchMetricRow {
metric: "Total",
today: format_u64_with(snapshot.totals.total_tokens, number_format),
per_model: token_cells(|usage| usage.total),
burn_rate: format_u64_with(snapshot.burn_rate.total_tokens_per_hour, number_format),
},
WatchMetricRow {
metric: "Cost",
today: format_currency(snapshot.totals.cost_usd),
per_model: cost_cells,
burn_rate: format_currency(snapshot.burn_rate.cost_usd_per_hour),
},
]
}
struct WatchBlockLayoutContext<'a> {
model_headers: &'a [String],
rows: &'a [WatchMetricRow],
snapshot: &'a WatchSnapshot,
render_config: TableRenderConfig,
terminal_width: usize,
}
impl WatchBlockLayoutContext<'_> {
fn best_layout_from(
&self,
model_start: usize,
include_today: bool,
best_tails: &[Option<Vec<WatchTableBlock>>],
) -> Option<Vec<WatchTableBlock>> {
let mut best_layout = None;
for model_end in (model_start + 1..=self.model_headers.len()).rev() {
let block = WatchTableBlock::new(
include_today,
model_start,
model_end,
model_end == self.model_headers.len(),
);
if !self.block_fits_or_forces(block) {
continue;
}
let candidate = if block.include_burn_rate {
Some(vec![block])
} else {
best_tails
.get(model_end)
.and_then(|tail| tail.as_ref())
.map(|tail| {
let mut layout = Vec::with_capacity(tail.len() + 1);
layout.push(block);
layout.extend(tail.iter().copied());
layout
})
};
if let Some(candidate) = candidate {
let should_replace = best_layout
.as_ref()
.is_none_or(|existing: &Vec<WatchTableBlock>| candidate.len() < existing.len());
if should_replace {
best_layout = Some(candidate);
}
}
}
best_layout
}
fn block_fits_or_forces(&self, block: WatchTableBlock) -> bool {
if self.block_width(block) <= self.terminal_width {
return true;
}
block.model_count() == 1
}
fn block_width(&self, block: WatchTableBlock) -> usize {
let headers = watch_block_headers(self.model_headers, &block);
let header_refs = headers.iter().map(String::as_str).collect::<Vec<_>>();
let projected_rows = watch_block_rows(self.rows, &block);
let updated_row = watch_updated_row(self.snapshot, &block);
let widths = column_widths(
&header_refs,
&projected_rows,
&updated_row,
self.render_config.number_format,
);
table_display_width(&widths)
}
}
fn watch_table_blocks(
model_headers: &[String],
rows: &[WatchMetricRow],
snapshot: &WatchSnapshot,
render_config: TableRenderConfig,
terminal_width: Option<usize>,
) -> Vec<WatchTableBlock> {
let full_block = WatchTableBlock::new(true, 0, model_headers.len(), true);
let Some(terminal_width) = terminal_width else {
return vec![full_block];
};
let layout = WatchBlockLayoutContext {
model_headers,
rows,
snapshot,
render_config,
terminal_width,
};
if model_headers.is_empty() || layout.block_width(full_block) <= terminal_width {
return vec![full_block];
}
let mut best_tails = vec![None; model_headers.len() + 1];
best_tails[model_headers.len()] = Some(Vec::new());
for model_start in (0..model_headers.len()).rev() {
best_tails[model_start] = layout.best_layout_from(model_start, false, &best_tails);
}
layout
.best_layout_from(0, true, &best_tails)
.unwrap_or_else(|| vec![full_block])
}
fn watch_block_headers(model_headers: &[String], block: &WatchTableBlock) -> Vec<String> {
let mut headers = Vec::with_capacity(block.model_count() + 3);
headers.push("Metric".to_string());
if block.include_today {
headers.push("Today".to_string());
}
headers.extend(
model_headers[block.model_start..block.model_end]
.iter()
.cloned(),
);
if block.include_burn_rate {
headers.push("Burn Rate (/h)".to_string());
}
headers
}
fn watch_block_rows(rows: &[WatchMetricRow], block: &WatchTableBlock) -> Vec<DisplayRow> {
rows.iter()
.map(|row| {
let mut cells = Vec::with_capacity(block.model_count() + 3);
cells.push(row.metric.to_string());
if block.include_today {
cells.push(row.today.clone());
}
cells.extend(
row.per_model[block.model_start..block.model_end]
.iter()
.cloned(),
);
if block.include_burn_rate {
cells.push(row.burn_rate.clone());
}
DisplayRow {
cells,
kind: DisplayRowKind::Subtotal,
}
})
.collect()
}
fn watch_updated_row(snapshot: &WatchSnapshot, block: &WatchTableBlock) -> DisplayRow {
let mut cells = Vec::with_capacity(block.model_count() + 3);
cells.push("Updated".to_string());
if block.include_today {
cells.push(snapshot.date.clone());
}
cells.extend(vec![String::new(); block.model_count()]);
if block.include_burn_rate {
cells.push(snapshot.updated_time.clone());
}
DisplayRow {
cells,
kind: DisplayRowKind::GrandTotal,
}
}
pub(super) fn explicit_usage(breakdown: &ModelBreakdown) -> UsageTotals {
UsageTotals {
input: breakdown
.input_tokens
.saturating_sub(breakdown.fallback_usage.input),
cached_input: breakdown
.cached_input_tokens
.saturating_sub(breakdown.fallback_usage.cached_input),
output: breakdown
.output_tokens
.saturating_sub(breakdown.fallback_usage.output),
reasoning_output: breakdown
.reasoning_output_tokens
.saturating_sub(breakdown.fallback_usage.reasoning_output),
total: breakdown
.total_tokens
.saturating_sub(breakdown.fallback_usage.total),
}
}
fn render_usage_table(
title: &str,
render_config: TableRenderConfig,
_locale: &str,
headers: &[&str],
rows: Vec<DisplayRow>,
totals: &Totals,
) -> String {
let mut all_rows = rows;
let grand_total_row = grand_total_row(headers, totals);
let widths = column_widths(
headers,
&all_rows,
&grand_total_row,
render_config.number_format,
);
all_rows.push(grand_total_row);
let mut output = String::new();
write_table_title(&mut output, render_config.style, title);
let _ = writeln!(&mut output);
write_table_header(&mut output, render_config, headers, &widths);
for row in all_rows {
if row.kind == DisplayRowKind::Spacer {
write_table_rule(
&mut output,
render_config.style,
table_rule_element(TableRuleKind::GroupSeparator),
&table_rule(
TableRuleKind::GroupSeparator,
render_config.borders,
&widths,
),
);
continue;
}
write_table_row(
&mut output,
render_config,
headers,
&widths,
&row.cells,
row_table_element(row.kind),
);
}
write_table_rule(
&mut output,
render_config.style,
table_rule_element(TableRuleKind::Bottom),
&table_rule(TableRuleKind::Bottom, render_config.borders, &widths),
);
output
}
fn grand_total_row(headers: &[&str], totals: &Totals) -> DisplayRow {
let cells = if headers.first() == Some(&"Directory") {
vec![
String::new(),
String::new(),
"GRAND TOTAL".to_string(),
totals.input_tokens.to_string(),
totals.cached_input_tokens.to_string(),
totals.output_tokens.to_string(),
totals.reasoning_output_tokens.to_string(),
totals.total_tokens.to_string(),
format_currency(totals.cost_usd),
String::new(),
]
} else {
vec![
String::new(),
"GRAND TOTAL".to_string(),
totals.input_tokens.to_string(),
totals.cached_input_tokens.to_string(),
totals.output_tokens.to_string(),
totals.reasoning_output_tokens.to_string(),
totals.total_tokens.to_string(),
format_currency(totals.cost_usd),
]
};
DisplayRow {
cells,
kind: DisplayRowKind::GrandTotal,
}
}
fn column_widths(
headers: &[&str],
rows: &[DisplayRow],
grand_total_row: &DisplayRow,
number_format: NumberFormat,
) -> Vec<usize> {
let mut widths = headers
.iter()
.map(|header| display_width(header))
.collect::<Vec<_>>();
for row in rows.iter().chain(std::iter::once(grand_total_row)) {
for (index, cell) in row.cells.iter().enumerate() {
widths[index] = widths[index].max(display_width(&format_table_cell(
headers,
index,
cell,
number_format,
)));
}
}
widths
}
fn write_table_title(output: &mut String, style: TableStyle, title: &str) {
let _ = writeln!(
output,
"{}",
paint(
style,
TableElement::Title,
&format!("{title} Codex Usage Report")
)
);
}
fn write_table_header(
output: &mut String,
render_config: TableRenderConfig,
headers: &[&str],
widths: &[usize],
) {
write_table_rule(
output,
render_config.style,
table_rule_element(TableRuleKind::Top),
&table_rule(TableRuleKind::Top, render_config.borders, widths),
);
let header_cells = headers
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
write_table_row(
output,
render_config,
headers,
widths,
&header_cells,
TableElement::Header,
);
write_table_rule(
output,
render_config.style,
table_rule_element(TableRuleKind::HeaderSeparator),
&table_rule(
TableRuleKind::HeaderSeparator,
render_config.borders,
widths,
),
);
}
#[cfg(test)]
pub(super) fn format_data_row(
headers: &[&str],
borders: BorderStyle,
widths: &[usize],
cells: &[String],
) -> String {
let theme = border_theme(borders);
let body = format_aligned_cells(headers, widths, cells, NumberFormat::Full)
.join(&theme.vertical.to_string());
format!("{}{}{}", theme.vertical, body, theme.vertical)
}
pub(super) fn write_table_row(
output: &mut String,
render_config: TableRenderConfig,
headers: &[&str],
widths: &[usize],
cells: &[String],
cell_element: TableElement,
) {
let theme = border_theme(render_config.borders);
let border = paint(
render_config.style,
TableElement::Border,
&theme.vertical.to_string(),
);
let separator = paint(
render_config.style,
TableElement::Border,
&theme.vertical.to_string(),
);
let styled_cells = format_aligned_cells(headers, widths, cells, render_config.number_format)
.into_iter()
.map(|cell| paint(render_config.style, cell_element, &cell))
.collect::<Vec<_>>();
let _ = writeln!(
output,
"{}{}{}",
border,
styled_cells.join(&separator),
border
);
}
fn format_aligned_cells(
headers: &[&str],
widths: &[usize],
cells: &[String],
number_format: NumberFormat,
) -> Vec<String> {
cells
.iter()
.enumerate()
.map(|(index, cell)| {
let display = format_table_cell(headers, index, cell, number_format);
let formatted = if should_left_align(headers[index], &display) {
format!("{display:width$}", width = widths[index])
} else {
format!("{display:>width$}", width = widths[index])
};
format!(" {formatted} ")
})
.collect()
}
fn format_table_cell(
headers: &[&str],
index: usize,
cell: &str,
number_format: NumberFormat,
) -> String {
if is_token_column(headers[index]) {
cell.parse::<u64>().map_or_else(
|_| cell.to_string(),
|value| format_u64_with(value, number_format),
)
} else {
cell.to_string()
}
}
fn is_token_column(header: &str) -> bool {
matches!(header, "Input" | "Cache" | "Output" | "Reasoning" | "Total")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum TableRuleKind {
Top,
HeaderSeparator,
GroupSeparator,
Bottom,
}
#[derive(Clone, Copy, Debug)]
struct BorderTheme {
horizontal: char,
vertical: char,
left: char,
middle: char,
right: char,
}
fn border_theme(style: BorderStyle) -> BorderTheme {
match style {
BorderStyle::Ascii => BorderTheme {
horizontal: '-',
vertical: '|',
left: '+',
middle: '+',
right: '+',
},
BorderStyle::Unicode => BorderTheme {
horizontal: '─',
vertical: '│',
left: '┌',
middle: '┬',
right: '┐',
},
}
}
fn rule_theme(kind: TableRuleKind, style: BorderStyle) -> BorderTheme {
match (style, kind) {
(BorderStyle::Ascii, _) => border_theme(style),
(BorderStyle::Unicode, TableRuleKind::Top) => BorderTheme {
horizontal: '─',
vertical: '│',
left: '┌',
middle: '┬',
right: '┐',
},
(BorderStyle::Unicode, TableRuleKind::HeaderSeparator | TableRuleKind::GroupSeparator) => {
BorderTheme {
horizontal: '─',
vertical: '│',
left: '├',
middle: '┼',
right: '┤',
}
}
(BorderStyle::Unicode, TableRuleKind::Bottom) => BorderTheme {
horizontal: '─',
vertical: '│',
left: '└',
middle: '┴',
right: '┘',
},
}
}
pub(super) fn table_rule(kind: TableRuleKind, borders: BorderStyle, widths: &[usize]) -> String {
let theme = rule_theme(kind, borders);
let segments = widths
.iter()
.map(|width| theme.horizontal.to_string().repeat(width + 2))
.collect::<Vec<_>>();
format!(
"{}{}{}",
theme.left,
segments.join(&theme.middle.to_string()),
theme.right
)
}
fn write_table_rule(output: &mut String, style: TableStyle, element: TableElement, line: &str) {
let _ = writeln!(output, "{}", paint(style, element, line));
}
fn table_display_width(widths: &[usize]) -> usize {
widths.iter().sum::<usize>() + (widths.len() * 3) + 1
}
fn display_width(value: &str) -> usize {
value.chars().count()
}
fn should_left_align(header: &str, display: &str) -> bool {
if header == "Today" && display.contains('-') {
return true;
}
matches!(
header,
"Date" | "Month" | "Metric" | "Directory" | "Session" | "Model" | "Last Activity"
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum TableElement {
Title,
Header,
Border,
Subtotal,
Detail,
GrandTotal,
}
fn row_table_element(kind: DisplayRowKind) -> TableElement {
match kind {
DisplayRowKind::Subtotal => TableElement::Subtotal,
DisplayRowKind::Spacer | DisplayRowKind::Detail => TableElement::Detail,
DisplayRowKind::GrandTotal => TableElement::GrandTotal,
}
}
fn table_rule_element(kind: TableRuleKind) -> TableElement {
match kind {
TableRuleKind::Top
| TableRuleKind::HeaderSeparator
| TableRuleKind::GroupSeparator
| TableRuleKind::Bottom => TableElement::Border,
}
}
pub(super) fn paint(style: TableStyle, element: TableElement, text: &str) -> String {
match style {
TableStyle::Plain => text.to_string(),
TableStyle::Ansi256 => {
let sequence = match element {
TableElement::Title => "\u{1b}[1;38;5;81m",
TableElement::Header => "\u{1b}[1;38;5;45m",
TableElement::Border => "\u{1b}[38;5;24m",
TableElement::Subtotal => "\u{1b}[1;38;5;117m",
TableElement::Detail => "\u{1b}[38;5;153m",
TableElement::GrandTotal => "\u{1b}[1;38;5;39m",
};
format!("{sequence}{text}\u{1b}[0m")
}
}
}
pub(super) fn format_u64(value: u64) -> String {
let raw = value.to_string();
let mut output = String::new();
let chars = raw.chars().rev().collect::<Vec<_>>();
for (index, character) in chars.iter().enumerate() {
if index > 0 && index % 3 == 0 {
output.push(',');
}
output.push(*character);
}
output.chars().rev().collect()
}
pub(super) fn format_u64_with(value: u64, number_format: NumberFormat) -> String {
match number_format {
NumberFormat::Full => format_u64(value),
NumberFormat::Short => format_u64_short(value),
}
}
fn format_u64_short(value: u64) -> String {
const UNITS: [(u64, &str); 4] = [
(1_000, "K"),
(1_000_000, "M"),
(1_000_000_000, "B"),
(1_000_000_000_000, "T"),
];
if value < UNITS[0].0 {
return value.to_string();
}
let mut unit_index = UNITS
.iter()
.enumerate()
.rfind(|(_index, (divisor, _suffix))| value >= *divisor)
.map_or(0, |(index, _unit)| index);
loop {
let (divisor, suffix) = UNITS[unit_index];
let whole = value / divisor;
let decimals: u32 = if whole >= 100 {
0
} else if whole >= 10 {
1
} else {
2
};
let multiplier = 10_u128.pow(decimals);
let rounded_units =
((u128::from(value) * multiplier) + (u128::from(divisor) / 2)) / u128::from(divisor);
if rounded_units >= 1_000 * multiplier && unit_index + 1 < UNITS.len() {
unit_index += 1;
continue;
}
return format_short_with_suffix(rounded_units, decimals, suffix);
}
}
fn format_short_with_suffix(value: u128, decimals: u32, suffix: &str) -> String {
if decimals == 0 {
return format!("{value}{suffix}");
}
let divisor = 10_u128.pow(decimals);
let integer = value / divisor;
let fractional = value % divisor;
let fractional_width = usize::try_from(decimals).expect("decimal width fits usize");
let mut number = format!("{integer}.{fractional:0fractional_width$}");
while number.ends_with('0') {
number.pop();
}
if number.ends_with('.') {
number.pop();
}
format!("{number}{suffix}")
}
pub(super) fn format_currency(value: f64) -> String {
format!("${value:.2}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn watch_layout_snapshot() -> WatchSnapshot {
WatchSnapshot {
date: "2026-01-02".to_string(),
totals: Totals {
input_tokens: 3,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: 3,
cost_usd: 0.03,
},
burn_rate: super::super::BurnRateSnapshot {
window_duration: Duration::from_secs(30 * 60),
window_minutes: 30,
input_tokens_per_hour: 6,
cached_input_tokens_per_hour: 0,
output_tokens_per_hour: 0,
reasoning_output_tokens_per_hour: 0,
total_tokens_per_hour: 6,
cost_usd_per_hour: 0.06,
},
per_model: BTreeMap::new(),
missing_directories: Vec::new(),
updated_time: "00:30:00".to_string(),
}
}
fn watch_layout_rows() -> Vec<WatchMetricRow> {
vec![WatchMetricRow {
metric: "Input",
today: "3".to_string(),
per_model: vec!["1".to_string(), "1".to_string(), "1".to_string()],
burn_rate: "6".to_string(),
}]
}
#[test]
fn watch_table_blocks_keep_single_block_when_width_is_sufficient() {
let blocks = watch_table_blocks(
&[
"alpha /h".to_string(),
"beta /h".to_string(),
"gamma /h".to_string(),
],
&watch_layout_rows(),
&watch_layout_snapshot(),
TableRenderConfig {
style: TableStyle::Plain,
borders: BorderStyle::Ascii,
number_format: NumberFormat::Full,
},
Some(120),
);
assert_eq!(blocks, vec![WatchTableBlock::new(true, 0, 3, true)]);
}
#[test]
fn watch_table_blocks_split_into_minimal_stacked_layout() {
let blocks = watch_table_blocks(
&[
"alpha /h".to_string(),
"beta /h".to_string(),
"gamma /h".to_string(),
],
&watch_layout_rows(),
&watch_layout_snapshot(),
TableRenderConfig {
style: TableStyle::Plain,
borders: BorderStyle::Ascii,
number_format: NumberFormat::Full,
},
Some(24),
);
assert_eq!(
blocks,
vec![
WatchTableBlock::new(true, 0, 1, false),
WatchTableBlock::new(false, 1, 2, false),
WatchTableBlock::new(false, 2, 3, true),
]
);
}
#[test]
fn watch_table_blocks_measure_unicode_borders_in_terminal_columns() {
let snapshot = watch_layout_snapshot();
let rows = watch_layout_rows();
let model_headers = vec!["alpha /h".to_string()];
let render_config = TableRenderConfig {
style: TableStyle::Plain,
borders: BorderStyle::Unicode,
number_format: NumberFormat::Full,
};
let layout = WatchBlockLayoutContext {
model_headers: &model_headers,
rows: &rows,
snapshot: &snapshot,
render_config,
terminal_width: usize::MAX,
};
let full_block = WatchTableBlock::new(true, 0, 1, true);
let measured_width = layout.block_width(full_block);
assert_eq!(measured_width, 52);
assert_eq!(
watch_table_blocks(
&model_headers,
&rows,
&snapshot,
render_config,
Some(measured_width),
),
vec![full_block]
);
}
#[test]
fn today_column_right_aligns_numbers_but_keeps_updated_date_readable() {
let headers = ["Metric", "Today", "Burn Rate (/h)"];
let widths = [7, 10, 14];
let numeric_row = format_data_row(
&headers,
BorderStyle::Ascii,
&widths,
&["Input".to_string(), "120".to_string(), "240".to_string()],
);
let updated_row = format_data_row(
&headers,
BorderStyle::Ascii,
&widths,
&[
"Updated".to_string(),
"2026-01-02".to_string(),
"00:30:00".to_string(),
],
);
let numeric_cells = numeric_row.split('|').collect::<Vec<_>>();
assert_eq!(numeric_cells[2], " 120 ");
let updated_cells = updated_row.split('|').collect::<Vec<_>>();
assert_eq!(updated_cells[2], " 2026-01-02 ");
}
}