Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18473,6 +18473,91 @@ mod tests {
assert!(painted, "no bar drawn:\n{out}");
}

/// Hovering a column details it as a bar per model: the model's name, a
/// horizontal bar in its own hue with the cache-served part in the darker
/// tone, and the exact figures the bar stands for (spec 0167).
#[tokio::test]
async fn hovering_a_meter_column_details_it_as_bars_per_model() {
let (mut app, _dir, _server) = token_meter_app(&[]).await;
// Two models in one bucket, one of them part cache-served, so the
// detail has to draw two rows and two tones.
for (model, tokens, cached) in [
("claude-opus-5", 18_000u64, 9_000u64),
("gpt-5.5", 6_000, 0),
] {
app.observe_cost_for_meter(
"s1",
&SessionEvent::Cost {
usd: 0.0,
tokens_in: tokens,
tokens_out: 0,
tokens_cached: cached,
model: Some(model.to_string()),
},
);
}
// First frame to learn where the graph is, then hover its newest
// column and draw again.
let _ = rendered(&mut app, 140, 40);
let graph = app
.layout
.matrix_token_graph_area
.expect("the meter drew a graph area");
app.mouse_pos = Some((graph.x + graph.width - 1, graph.y + graph.height / 2));
let out = rendered(&mut app, 140, 40);

assert!(
out.contains("24k tok"),
"the header names the column's total:\n{out}"
);
assert!(
out.contains("18k") && out.contains("9.0k cached"),
"each row keeps its exact figures, cached named as a subset:\n{out}"
);
// The bars are paint, not text — and paint made entirely of background
// fills, so a bar is a clean rectangle on every font (#1183). Find the
// row carrying the opus figures and look at what it painted.
let backend = ratatui::backend::TestBackend::new(140, 40);
let mut term = ratatui::Terminal::new(backend).expect("terminal");
term.draw(|f| crate::ui::render(f, &mut app)).expect("draw");
let buf = term.backend().buffer().clone();
let row_text = |y: u16| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
};
let bar_row = (0..buf.area.height)
.find(|y| row_text(*y).contains("18k"))
.expect("the detail's opus row");
let filled: Vec<usize> = (0..buf.area.width as usize)
.filter(|x| {
let cell = &buf[(*x as u16, bar_row)];
cell.bg != ratatui::style::Color::Reset && cell.symbol() == " "
})
.collect();
// 18k of 24k over a 20-cell bar is 15 cells, in two tones.
assert_eq!(filled.len(), 15, "bar length is the model's share");
assert!(
filled.windows(2).all(|w| w[1] == w[0] + 1),
"the bar is one contiguous run: {filled:?}"
);
let tones: std::collections::HashSet<_> = filled
.iter()
.map(|x| format!("{:?}", buf[(*x as u16, bar_row)].bg))
.collect();
assert_eq!(
tones.len(),
2,
"cache-served and fresh volume are two tones of one hue"
);
assert!(
filled
.iter()
.all(|x| buf[(*x as u16, bar_row)].symbol() == " "),
"no glyph in the bar: a partial block would notch its corner"
);
}

/// Tokens with no measured compute time read as `idle`, not as `0/s`:
/// the client can't have watched the work, so it states nothing about
/// how fast it was rather than claiming it was slow.
Expand Down
73 changes: 70 additions & 3 deletions crates/cli/src/project_dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ pub struct ProjectDashboard {
pub last_seen: HashMap<String, (SessionState, bool)>,
/// Hit zones from the last render.
pub hits: ProjectDashboardHits,
/// `(project_id, graph rect)` of the meter drawn on the last frame, so the
/// hover detail can find which bucket the pointer is over. `None` on any
/// frame that drew no meter (idle project, or a pane too short for one).
pub meter_graph: Option<(String, Rect)>,
}

impl Default for ProjectDashboard {
Expand All @@ -150,6 +154,7 @@ impl Default for ProjectDashboard {
token_meters: HashMap::new(),
last_seen: HashMap::new(),
hits: ProjectDashboardHits::default(),
meter_graph: None,
}
}
}
Expand Down Expand Up @@ -651,6 +656,7 @@ pub fn render(
dashboard.ensure_project(project_id);
dashboard.clamp_cursor(members.len());
dashboard.hits = ProjectDashboardHits::default();
dashboard.meter_graph = None;

if area.width == 0 || area.height == 0 {
return;
Expand Down Expand Up @@ -730,6 +736,9 @@ pub fn render(
// ── Token meter (C2) ────────────────────────────────────────────────
let meter = dashboard.token_meters.get_mut(project_id);
let show_meter = area.height >= 12 && w >= 24;
// Recorded after the meter's own borrow ends, so the hover detail can map
// a pointer back to a bucket on the next frame.
let mut meter_graph = None;
if show_meter {
if let Some(meter) = meter {
meter.advance_to(now);
Expand All @@ -741,7 +750,7 @@ pub fn render(
width: w,
height: meter_h,
};
render_project_meter(f, meter_area, theme, meter, now);
meter_graph = render_project_meter(f, meter_area, theme, meter, now);
row = row.saturating_add(meter_h);
}
} else if row < bottom {
Expand All @@ -760,6 +769,7 @@ pub fn render(
row = row.saturating_add(1);
}
}
dashboard.meter_graph = meter_graph.map(|graph| (project_id.to_string(), graph));

// Gap before body columns.
if row < bottom {
Expand Down Expand Up @@ -841,20 +851,22 @@ pub fn render(
}
}

/// Returns the rect the columns occupy, so the caller can record it as the
/// hover-detail hit zone (the legend row below them is not part of it).
fn render_project_meter(
f: &mut Frame,
area: Rect,
theme: &Theme,
meter: &TokenMeter,
_now: Instant,
) {
) -> Option<Rect> {
let dim = Style::default().fg(theme.dim);
if meter.is_idle() {
f.render_widget(
Paragraph::new(Span::styled(" no token usage reported yet ", dim)),
area,
);
return;
return None;
}

let graph_h = area.height.saturating_sub(1).max(1);
Expand Down Expand Up @@ -917,6 +929,7 @@ fn render_project_meter(
height: 1,
},
);
Some(graph)
}

fn render_members(
Expand Down Expand Up @@ -1478,4 +1491,58 @@ mod tests {
assert_eq!(members[0].id, "live");
let _ = live;
}

/// The dashboard's meter records the rect its columns occupy so the hover
/// detail can map a pointer back to a bucket, and records nothing on a
/// frame that drew no columns — a stale rect would keep answering hovers
/// over whatever replaced it.
#[test]
fn the_meter_records_its_graph_rect_only_while_it_draws_one() {
let live = session("live", Some("p"), SessionState::Running, false);
let members = vec![&live];
let mut dash = ProjectDashboard::default();
let theme = Theme::default();
let now = Instant::now();
let now_ms = Utc::now().timestamp_millis();
let area = Rect {
x: 0,
y: 0,
width: 90,
height: 30,
};
let draw = |dash: &mut ProjectDashboard| {
let backend = ratatui::backend::TestBackend::new(area.width, area.height);
let mut term = ratatui::Terminal::new(backend).expect("terminal");
term.draw(|f| render(f, area, &theme, "p", &members, dash, true, now, now_ms))
.expect("draw");
};

// No meter for this project yet: nothing to hover.
draw(&mut dash);
assert_eq!(dash.meter_graph, None);

let mut meter = TokenMeter::new(now);
meter.observe(Some("claude-opus-5"), 12_000, 4_000, now);
dash.token_meters.insert("p".into(), meter);
draw(&mut dash);
let (project, graph) = dash.meter_graph.clone().expect("the meter drew columns");
assert_eq!(project, "p");
assert!(graph.width > 0 && graph.height > 0, "{graph:?}");
assert!(
graph.y + graph.height < area.y + area.height,
"the graph rect stops above the legend row: {graph:?}"
);

// A pane too short for a meter draws none, and clears the rect.
let short = Rect { height: 10, ..area };
let backend = ratatui::backend::TestBackend::new(short.width, short.height);
let mut term = ratatui::Terminal::new(backend).expect("terminal");
term.draw(|f| {
render(
f, short, &theme, "p", &members, &mut dash, true, now, now_ms,
)
})
.expect("draw");
assert_eq!(dash.meter_graph, None);
}
}
53 changes: 52 additions & 1 deletion crates/cli/src/token_meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,9 +806,23 @@ pub fn band_cell<K: BandPaint>(row: usize, band: K, fill: usize) -> ColumnCell<K
/// their tokens, largest-remainder so the parts sum to exactly `filled` and
/// no visible band rounds away to nothing.
pub fn stacked_eighths<K: Copy>(stacked: &[(K, u64)], total: u64, filled: usize) -> Vec<(K, usize)> {
if total == 0 || filled == 0 {
split_units(stacked, total, filled)
}

/// Hand out `units` of drawing space to bands in proportion to their tokens,
/// largest-remainder so the parts sum to exactly `units` and no band that has
/// volume rounds away to nothing.
///
/// A column's unit is an eighth of a cell (see [`stacked_eighths`]); a
/// horizontal bar's unit is a whole cell, because a bar drawn along a row has
/// to end on a cell boundary to keep a square edge — see the hover detail in
/// the TUI renderer.
pub fn split_units<K: Copy>(parts: &[(K, u64)], total: u64, units: usize) -> Vec<(K, usize)> {
if total == 0 || units == 0 {
return Vec::new();
}
let stacked = parts;
let filled = units;
let mut out: Vec<(K, usize)> = Vec::with_capacity(stacked.len());
let mut remainders: Vec<(usize, f64)> = Vec::with_capacity(stacked.len());
let mut assigned = 0usize;
Expand Down Expand Up @@ -1331,6 +1345,43 @@ mod tests {
);
}

/// A horizontal bar spends whole cells, so `split_units` has to hand out
/// exactly the cells asked for and give a band with volume at least one —
/// the hover detail's bars have no sub-cell resolution to fall back on.
#[test]
fn split_units_hands_out_whole_cells_exactly() {
let parts = [((0u16, Part::Cached), 2_000u64), ((0u16, Part::New), 3_000)];
let cells = split_units(&parts, 5_000, 15);
assert_eq!(cells.iter().map(|(_, n)| *n).sum::<usize>(), 15);
assert_eq!(cells[0], ((0, Part::Cached), 6), "2/5 of 15 cells");
assert_eq!(cells[1], ((0, Part::New), 9));

// A part small enough to floor to zero still takes a cell, at the
// expense of the largest one — a band drawn as nothing would
// contradict the figure printed beside it.
let lopsided = split_units(
&[((0u16, Part::Cached), 1u64), ((0u16, Part::New), 999)],
1_000,
8,
);
assert_eq!(lopsided.iter().map(|(_, n)| *n).sum::<usize>(), 8);
assert!(
lopsided.iter().all(|(_, n)| *n >= 1),
"no band vanishes: {lopsided:?}"
);
}

/// The column stack is the same split at eighth resolution, so the two
/// entry points cannot drift.
#[test]
fn stacked_eighths_is_the_same_split_in_eighths() {
let parts = [((0u16, Part::Cached), 1_000u64), ((1u16, Part::New), 3_000)];
assert_eq!(
stacked_eighths(&parts, 4_000, 32),
split_units(&parts, 4_000, 32)
);
}

/// A column's segments sum to exactly the column's height — no drift
/// from rounding each share independently — and stay ordered by size.
#[test]
Expand Down
Loading
Loading