diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 0ef66081..77baeae8 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2368,6 +2368,10 @@ pub struct App { /// 0167). Keyed by session id; entries for vanished sessions are pruned /// as they are noticed. pub token_meter_busy: HashMap, + /// Service name → live token history for sessions routed by that service. + /// Fed alongside the fleet and project meters even while no service view + /// is open, so opening one reveals the activity already observed. + pub service_token_meters: HashMap, /// Project dashboard state: activity feed, per-project token meters, /// chat-preview cache, and cursor when a project header is selected. pub project_dashboard: crate::project_dashboard::ProjectDashboard, @@ -4154,6 +4158,10 @@ pub struct LayoutSnapshot { pub service_channel_row_hits: Vec, /// Visible Publish/Withdraw/Open/Copy buttons for the selected channel. pub service_channel_action_hits: Vec, + /// `(service name, graph rect)` for every service-scoped token meter + /// painted in the last frame. Split panes can show more than one service, + /// so this is a collection rather than a single active hit zone. + pub service_token_graphs: Vec<(String, ratatui::layout::Rect)>, /// The "▸/▾ N subagents" group-toggle rows — click toggles that /// parent's group between collapsed and expanded. pub lineage_subagent_toggle_hits: Vec, @@ -4482,6 +4490,7 @@ impl LayoutSnapshot { service_session_hits, service_channel_row_hits, service_channel_action_hits, + service_token_graphs, lineage_subagent_toggle_hits, playbook_title_run_hit, playbook_title_toggle_hit, @@ -4628,6 +4637,7 @@ impl LayoutSnapshot { shift.retain_rects(service_session_hits, |hit| &mut hit.area); shift.retain_rects(service_channel_row_hits, |hit| &mut hit.area); shift.retain_rects(service_channel_action_hits, |hit| &mut hit.area); + shift.retain_rects(service_token_graphs, |hit| &mut hit.1); shift.retain_rects(lineage_subagent_toggle_hits, |hit| &mut hit.area); *dynamic_ui_inline_hit = dynamic_ui_inline_hit.take().and_then(|mut hit| { @@ -5500,20 +5510,28 @@ async fn run_with_socket_initial_selection( matrix_panel_mode: persisted.matrix_panel_mode, token_meter: { let mut meter = crate::token_meter::TokenMeter::new(now); - if let Some(history) = token_history { + if let Some(history) = token_history.as_ref() { // Ages are measured against the daemon's own clock reading, // not this process's, so the two never have to agree. meter.reserve_history(TOKEN_HISTORY_WINDOW_SECS as u64); meter.seed( history .samples - .into_iter() - .map(|s| (history.now_ms - s.at_ms, s.model, s.tokens, s.cached)), + .iter() + .map(|s| { + ( + history.now_ms - s.at_ms, + s.model.clone(), + s.tokens, + s.cached, + ) + }), ); } meter }, token_meter_busy: HashMap::new(), + service_token_meters: HashMap::new(), project_dashboard: crate::project_dashboard::ProjectDashboard::default(), show_archived_ungrouped: false, show_archived_groups: HashSet::new(), @@ -5530,6 +5548,9 @@ async fn run_with_socket_initial_selection( pty_input_errors, tutorial: None, }; + if let Some(history) = token_history.as_ref() { + app.seed_scoped_token_meters(history, now); + } if let Some(step) = persisted.tutorial_step { app.tutorial_resume(step); } @@ -9656,12 +9677,64 @@ impl App { .unwrap_or(Selection::None); } - /// True when the session is currently rendered somewhere on screen: - /// a pane of the active window (including splits), the orchestrator - /// panel. Used to decide whether a background `Pty` chunk needs an - /// immediate full-frame repaint. Conservative — treats the orchestrator - /// as always-visible so the gate never drops a needed redraw; the cost - /// of an occasional extra paint is far cheaper than missing one. + /// Rebuild project- and service-scoped histories from the daemon's fleet + /// window. The session id on each sample supplies the missing filter; + /// current summaries supply project membership and service ancestry. + fn seed_scoped_token_meters( + &mut self, + history: &construct_protocol::TokenHistoryResult, + now: Instant, + ) { + type SeedSample = (i64, Option, u64, u64); + + let mut project_samples: HashMap> = HashMap::new(); + let mut service_samples: HashMap> = HashMap::new(); + for sample in &history.samples { + let Some(session_id) = sample.session_id.as_deref() else { + // An older daemon can still seed the fleet meter, but its + // samples predate session attribution on the wire. + continue; + }; + let Some(session) = self.sessions.iter().find(|session| session.id == session_id) + else { + continue; + }; + let seed = ( + history.now_ms - sample.at_ms, + sample.model.clone(), + sample.tokens, + sample.cached, + ); + if let Some(project_id) = session.group_id.as_ref() { + project_samples + .entry(project_id.clone()) + .or_default() + .push(seed.clone()); + } + if let Some(service_name) = self.routed_service_name(session) { + service_samples + .entry(service_name.to_string()) + .or_default() + .push(seed); + } + } + + for (project_id, samples) in project_samples { + let mut meter = crate::token_meter::TokenMeter::new(now); + meter.reserve_history(TOKEN_HISTORY_WINDOW_SECS as u64); + meter.seed(samples); + self.project_dashboard + .token_meters + .insert(project_id, meter); + } + for (service_name, samples) in service_samples { + let mut meter = crate::token_meter::TokenMeter::new(now); + meter.reserve_history(TOKEN_HISTORY_WINDOW_SECS as u64); + meter.seed(samples); + self.service_token_meters.insert(service_name, meter); + } + } + /// Bin one session's `Cost` report into the fleet token meter (spec /// 0167). Attribution prefers the model the report itself names; when /// the harness stated none, the session's currently-tracked model stands @@ -9688,6 +9761,9 @@ impl App { let label = model .clone() .or_else(|| session.and_then(|s| s.model.clone())); + let service_name = session + .and_then(|session| self.routed_service_name(session)) + .map(str::to_string); let now = Instant::now(); self.token_meter .observe(label.as_deref(), tokens, *tokens_cached, now); @@ -9702,6 +9778,12 @@ impl App { now, ); } + if let Some(service_name) = service_name { + self.service_token_meters + .entry(service_name) + .or_insert_with(|| crate::token_meter::TokenMeter::new(now)) + .observe(label.as_deref(), tokens, *tokens_cached, now); + } } /// Feed the token meter the compute time that elapsed since the last @@ -9719,7 +9801,7 @@ impl App { fn sample_compute_time(&mut self, now: Instant) { let now_ms = chrono::Utc::now().timestamp_millis(); let mut seen: HashSet<&str> = HashSet::with_capacity(self.sessions.len()); - let mut deltas: Vec<(Option, Option, u64)> = Vec::new(); + let mut deltas: Vec<(Option, Option, Option, u64)> = Vec::new(); for session in &self.sessions { let Some(model) = session.model.clone() else { continue; @@ -9733,14 +9815,20 @@ impl App { if let Some(previous) = previous { let delta = busy.saturating_sub(previous); if delta > 0 { - deltas.push((Some(model), session.group_id.clone(), delta)); + let service_name = self.routed_service_name(session).map(str::to_string); + deltas.push(( + Some(model), + session.group_id.clone(), + service_name, + delta, + )); } } self.token_meter_busy.insert(session.id.clone(), busy); } let live: HashSet = seen.into_iter().map(str::to_string).collect(); self.token_meter_busy.retain(|id, _| live.contains(id)); - for (model, project_id, delta) in deltas { + for (model, project_id, service_name, delta) in deltas { self.token_meter.observe_busy(model.as_deref(), delta, now); if let Some(project_id) = project_id { self.project_dashboard.observe_busy( @@ -9750,9 +9838,21 @@ impl App { now, ); } + if let Some(service_name) = service_name { + self.service_token_meters + .entry(service_name) + .or_insert_with(|| crate::token_meter::TokenMeter::new(now)) + .observe_busy(model.as_deref(), delta, now); + } } } + /// True when the session is currently rendered somewhere on screen: + /// a pane of the active window (including splits), or the orchestrator + /// panel. Used to decide whether a background `Pty` chunk needs an + /// immediate full-frame repaint. Conservative — treats the orchestrator + /// as always-visible so the gate never drops a needed redraw; the cost + /// of an occasional extra paint is far cheaper than missing one. fn session_visible_on_screen(&self, id: &str) -> bool { if self .main_windows @@ -16868,6 +16968,7 @@ mod tests { service_session_hits: Vec::new(), service_channel_row_hits: Vec::new(), service_channel_action_hits: Vec::new(), + service_token_graphs: Vec::new(), lineage_subagent_toggle_hits: Vec::new(), lineage_segment_tooltip: None, playbook_title_run_hit: None, @@ -17196,6 +17297,7 @@ mod tests { matrix_panel_mode: MatrixPanelMode::default(), token_meter: crate::token_meter::TokenMeter::new(now), token_meter_busy: HashMap::new(), + service_token_meters: HashMap::new(), project_dashboard: crate::project_dashboard::ProjectDashboard::default(), show_archived_ungrouped: false, show_archived_groups: HashSet::new(), @@ -18652,6 +18754,97 @@ mod tests { assert!(out.contains("kimi-k3"), "{out}"); } + /// A routed session's measured compute time feeds the same service scope + /// as its Cost events, so the service graph reports throughput rather than + /// an idle token total. + #[tokio::test] + async fn service_view_meter_tracks_routed_session_compute_time() { + let (mut app, _dir, _server) = token_meter_app(&[]).await; + app.services.push(service_summary_for_test("assistant")); + app.sessions[0].title = Some("service:assistant:http:conversation".into()); + app.sessions[0].model = Some("opus".into()); + app.observe_cost_for_meter( + "s1", + &SessionEvent::Cost { + usd: 0.0, + tokens_in: 100_000, + tokens_out: 0, + tokens_cached: 40_000, + model: Some("opus".into()), + }, + ); + + let now = Instant::now(); + app.sample_compute_time(now); // establish the per-session baseline + app.sessions[0].busy_ms = 20_000; + app.sample_compute_time(now); + + let meter = app + .service_token_meters + .get("assistant") + .expect("the service meter should receive routed activity"); + assert_eq!(meter.window_total(64), 100_000); + assert_eq!(meter.recent_fleet_rate(), Some(5_000.0)); + } + + /// A fresh TUI rebuilds scoped graph buckets from the daemon's durable + /// fleet window. Session identity is what lets the same samples be + /// filtered without leaking unrelated project or service activity. + #[tokio::test] + async fn scoped_token_meters_seed_from_daemon_history_after_restart() { + let (mut app, _dir, _server) = token_meter_app(&[]).await; + app.services.push(service_summary_for_test("assistant")); + + app.sessions[0].id = "routed-root".into(); + app.sessions[0].title = Some("service:assistant:http:conversation".into()); + app.sessions[0].group_id = Some("project-a".into()); + + let mut descendant = summary_with_kind(construct_protocol::SessionKind::User); + descendant.id = "descendant".into(); + descendant.parent_session_id = Some("routed-root".into()); + descendant.group_id = Some("project-a".into()); + + let mut unrelated = summary_with_kind(construct_protocol::SessionKind::User); + unrelated.id = "unrelated".into(); + unrelated.group_id = Some("project-b".into()); + app.sessions.extend([descendant, unrelated]); + + let sample = |session_id: Option<&str>, tokens| construct_protocol::TokenSample { + at_ms: 9_000, + session_id: session_id.map(str::to_string), + model: Some("opus".into()), + tokens, + cached: tokens / 2, + }; + let history = construct_protocol::TokenHistoryResult { + samples: vec![ + sample(Some("routed-root"), 100), + sample(Some("descendant"), 200), + sample(Some("unrelated"), 400), + // Backward-compatible history from an older daemon has no + // session id and must not be guessed into either scope. + sample(None, 800), + ], + now_ms: 10_000, + }; + + app.seed_scoped_token_meters(&history, Instant::now()); + + assert_eq!( + app.project_dashboard.token_meters["project-a"].window_total(64), + 300 + ); + assert_eq!( + app.project_dashboard.token_meters["project-b"].window_total(64), + 400 + ); + assert_eq!( + app.service_token_meters["assistant"].window_total(64), + 300, + "the routed root and descendant seed the service, unrelated history does not" + ); + } + /// The picker opens on the model indicator and lists Default plus /// every target, unavailable ones included (spec 0115). #[tokio::test] @@ -42278,6 +42471,7 @@ mod tests { ); assert!(text.contains("Instruction")); assert!(text.contains("Service name")); + assert!(text.contains("no token usage reported yet for this service")); assert!(text.contains("Channels")); assert!(text.contains("Sessions")); assert!(!text.contains("Activity")); @@ -42497,7 +42691,7 @@ mod tests { } #[tokio::test] - async fn service_view_session_rows_show_channel_and_select_session_on_click() { + async fn service_view_draws_scoped_token_graph_and_session_rows_are_clickable() { use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; let (mut app, _dir, server) = captured_app().await; @@ -42506,7 +42700,38 @@ mod tests { routed.title = Some("service:assistant:http:demo-conversation".into()); routed.state = construct_protocol::SessionState::AwaitingInput; app.sessions.push(routed); + let mut second = summary_with_kind(construct_protocol::SessionKind::User); + second.id = "second-service-session".into(); + second.title = Some("service:assistant:http:another-conversation".into()); + app.sessions.push(second); + let mut child = summary_with_kind(construct_protocol::SessionKind::User); + child.id = "service-child".into(); + child.title = Some("native helper".into()); + child.parent_session_id = Some("service-session".into()); + app.sessions.push(child); + let mut other_service = summary_with_kind(construct_protocol::SessionKind::User); + other_service.id = "other-service-session".into(); + other_service.title = Some("service:reviewer:http:review".into()); + app.sessions.push(other_service); app.services.push(service_summary_for_test("assistant")); + app.services.push(service_summary_for_test("reviewer")); + for (session_id, input, output, cached, model) in [ + ("service-session", 120_000, 10_000, 80_000, "opus"), + ("second-service-session", 30_000, 3_000, 20_000, "gpt"), + ("service-child", 7_000, 0, 0, "helper-model"), + ("other-service-session", 1_000_000, 0, 0, "reviewer-model"), + ] { + app.observe_cost_for_meter( + session_id, + &SessionEvent::Cost { + usd: 0.0, + tokens_in: input, + tokens_out: output, + tokens_cached: cached, + model: Some(model.into()), + }, + ); + } app.select_service("assistant".into()); app.session_transitions.clear(); @@ -42514,9 +42739,8 @@ mod tests { let mut term = ratatui::Terminal::new(backend).expect("terminal"); term.draw(|f| crate::ui::render(f, &mut app)).expect("draw"); - let text = term - .backend() - .buffer() + let buffer = term.backend().buffer(); + let text = buffer .content() .iter() .map(|cell| cell.symbol()) @@ -42526,6 +42750,52 @@ mod tests { "rendered service view:\n{text}" ); assert!(text.contains("demo-conversation")); + assert!( + text.contains("opus") && text.contains("gpt") && text.contains("helper-model"), + "the graph legend should name every model used by this service:\n{text}" + ); + assert!(!text.contains("reviewer-model"), "another service leaked in:\n{text}"); + let (meter_service, graph) = app + .layout + .service_token_graphs + .first() + .cloned() + .expect("the service view should paint a token graph"); + assert_eq!(meter_service, "assistant"); + assert_eq!( + graph.height, + crate::project_dashboard::METER_HEIGHT - 1, + "the service graph should use the full scoped meter height" + ); + let meter = app.service_token_meters.get("assistant").unwrap(); + let entries = meter.legend(graph.width as usize); + let legend_y = graph.y + graph.height; + assert_eq!( + buffer[(graph.x, legend_y)].fg, + entries[0].dot_color, + "the service legend dot should use the operator legend's cache tone" + ); + assert_eq!( + buffer[(graph.x + 2, legend_y)].fg, + entries[0].color, + "the service legend text should use the operator legend's model color" + ); + let painted = (graph.y..graph.y + graph.height) + .flat_map(|y| (graph.x..graph.x + graph.width).map(move |x| (x, y))) + .any(|(x, y)| { + let cell = &buffer[(x, y)]; + cell.bg != ratatui::style::Color::Reset || "▁▂▃▄▅▆▇".contains(cell.symbol()) + }); + assert!(painted, "the service meter has no painted bars:\n{text}"); + + app.mouse_pos = Some((graph.x + graph.width - 1, graph.y)); + term.draw(|f| crate::ui::render(f, &mut app)) + .expect("hover draw"); + let hover_text = rendered_text(term.backend().buffer()); + assert!( + hover_text.contains("80k cached"), + "the service graph should share exact per-column hover detail:\n{hover_text}" + ); let hit = app .layout diff --git a/crates/cli/src/app/service_dialog.rs b/crates/cli/src/app/service_dialog.rs index 369086ad..c9be3569 100644 --- a/crates/cli/src/app/service_dialog.rs +++ b/crates/cli/src/app/service_dialog.rs @@ -514,6 +514,8 @@ impl App { .cmp(&b.position) .then_with(|| a.name.cmp(&b.name)) }); + self.service_token_meters + .retain(|name, _| services.iter().any(|service| service.name == *name)); self.services = services; } Err(error) => self.set_status(format!("services refresh failed: {error}")), @@ -543,6 +545,39 @@ impl App { .collect() } + /// Service whose routing namespace owns this session or one of its + /// ancestors. Native subagents and forks contribute to the service that + /// owns their routed root even when their own title has no service prefix. + pub fn routed_service_name<'a>(&'a self, session: &SessionSummary) -> Option<&'a str> { + let mut current = session; + // Session ancestry is acyclic by contract. The bound is a defensive + // stop for malformed summaries so meter attribution can never loop. + for _ in 0..=self.sessions.len() { + if let Some(service_name) = current + .title + .as_deref() + .and_then(|title| title.strip_prefix("service:")) + .and_then(|suffix| suffix.split(':').next()) + { + if let Some(service) = self + .services + .iter() + .find(|service| service.name == service_name) + { + return Some(service.name.as_str()); + } + } + let parent_id = current + .native_subagent + .as_ref() + .map(|native| native.owner_session_id.as_str()) + .or(current.parent_session_id.as_deref()) + .or_else(|| current.forked_from.as_ref().map(|fork| fork.session_id.as_str()))?; + current = self.sessions.iter().find(|candidate| candidate.id == parent_id)?; + } + None + } + /// Navigable row counts below the definition fields. The channel section /// always offers one row: with an empty catalog it is the "create one" /// affordance, which is the only way to reach channel creation by keyboard. diff --git a/crates/cli/src/project_dashboard.rs b/crates/cli/src/project_dashboard.rs index 07fac517..3c2a4e4d 100644 --- a/crates/cli/src/project_dashboard.rs +++ b/crates/cli/src/project_dashboard.rs @@ -19,10 +19,12 @@ use ratatui::Frame; use unicode_width::UnicodeWidthStr; use crate::theme::Theme; -use crate::token_meter::{self, TokenMeter}; +use crate::token_meter::TokenMeter; -/// Compact meter height when the pane is tall enough. -const METER_HEIGHT: u16 = 4; +/// Compact meter height when the pane is tall enough. One row belongs to the +/// shared legend, leaving four graph rows — roughly 30% taller than the +/// previous three-row graph while keeping the scoped dashboards compact. +pub(crate) const METER_HEIGHT: u16 = 5; /// Rows kept for member cards even when the meter would like more. const CARDS_MIN_HEIGHT: u16 = 6; @@ -642,7 +644,7 @@ pub fn render( width: w, height: meter_h, }; - meter_graph = render_project_meter(f, meter_area, theme, meter, now); + meter_graph = crate::ui::render_token_meter_surface(f, meter_area, theme, meter); row = row.saturating_add(meter_h); } } else if row < bottom { @@ -687,87 +689,6 @@ 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 { - 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 None; - } - - let graph_h = area.height.saturating_sub(1).max(1); - let graph = Rect { - x: area.x, - y: area.y, - width: area.width, - height: graph_h, - }; - let width = graph.width as usize; - let scale = meter.scale(width).max(1); - let cells = graph.height as usize; - let eighths_total = cells * 8; - let history: Vec<_> = meter.window(width).collect(); - let hist_len = history.len(); - - // Same column paint as the fleet token meter (#1183): full cells as - // background fill so fonts whose FULL BLOCK is short of the line box - // don't leave hairline seams between rows. - for (col, bucket) in history.iter().enumerate() { - let x = graph.x + (width - hist_len + col) as u16; - let total = bucket.total(); - if total == 0 { - continue; - } - let filled = ((total as f64 / scale as f64) * eighths_total as f64).round() as usize; - let filled = filled.clamp(1, eighths_total); - let segments = token_meter::stacked_eighths(&bucket.stacked(), total, filled); - for cell in token_meter::column_cells(&segments, filled, cells) { - let y = graph.y + graph.height.saturating_sub(cell.row + 1); - let mut style = Style::default().fg(meter.band_color(cell.fg)); - if let Some(bg) = cell.bg { - style = style.bg(meter.band_color(bg)); - } - f.buffer_mut().set_string(x, y, cell.glyph, style); - } - } - - // Legend / rate line. - let legend_y = area.y.saturating_add(area.height.saturating_sub(1)); - let entries = meter.legend(width.min(area.width as usize)); - let rate = meter - .recent_fleet_rate() - .map(|r| format!("{:.0}/s", r)) - .unwrap_or_else(|| "—".into()); - let total = format_token_count(meter.window_total(width)); - let mut legend = format!(" {total} tok · {rate}"); - for e in entries.iter().take(3) { - legend.push_str(&format!(" {} {}", "●", e.label)); - } - f.render_widget( - Paragraph::new(Span::styled( - truncate_width(&legend, area.width as usize), - dim, - )), - Rect { - x: area.x, - y: legend_y, - width: area.width, - height: 1, - }, - ); - Some(graph) -} - /// Rows one member card occupies given the pane's shape: 1 (title only) on /// cramped panes, 2 (title + content), or 3 (a breathing row between cards) /// when every member fits airily. @@ -1208,23 +1129,38 @@ mod tests { ) }) .expect("draw"); + term.backend().buffer().clone() }; // No meter for this project yet: nothing to hover. - draw(&mut dash); + let _ = 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 buffer = 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_eq!(graph.height, METER_HEIGHT - 1, "one row is the legend"); assert!( graph.y + graph.height < area.y + area.height, "the graph rect stops above the legend row: {graph:?}" ); + let entries = dash.token_meters["p"].legend(graph.width as usize); + let legend_y = graph.y + graph.height; + assert_eq!(buffer[(graph.x, legend_y)].fg, entries[0].dot_color); + assert_eq!(buffer[(graph.x + 2, legend_y)].fg, entries[0].color); + let legend: String = (graph.x..graph.x + graph.width) + .map(|x| buffer[(x, legend_y)].symbol()) + .collect(); + assert!( + legend.contains("● claude-opus-5") + && legend.contains("idle") + && legend.contains("Σ idle"), + "project legend should use the operator legend layout: {legend:?}" + ); // A pane too short for a meter draws none, and clears the rect. let short = Rect { height: 10, ..area }; diff --git a/crates/cli/src/token_meter.rs b/crates/cli/src/token_meter.rs index 6d63291f..ce6d2f79 100644 --- a/crates/cli/src/token_meter.rs +++ b/crates/cli/src/token_meter.rs @@ -627,7 +627,8 @@ impl TokenMeter { named } - /// Total tokens across the visible window — the header readout. + /// Total tokens across a visible window, used by accounting tests. + #[cfg(test)] pub fn window_total(&self, width: usize) -> u64 { self.window(width) .fold(0u64, |acc, b| acc.saturating_add(b.total())) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 6fd0b16c..844940a5 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -327,6 +327,7 @@ pub fn render(f: &mut Frame, app: &mut App) { app.layout.service_session_hits.clear(); app.layout.service_channel_row_hits.clear(); app.layout.service_channel_action_hits.clear(); + app.layout.service_token_graphs.clear(); app.layout.lineage_subagent_toggle_hits.clear(); app.layout.lineage_segment_tooltip = None; // Cleared here rather than only in the dashboard's own render, which every @@ -442,6 +443,7 @@ pub fn render(f: &mut Frame, app: &mut App) { render_lineage_segment_tooltip(f, app); render_matrix_token_tooltip(f, app); render_project_meter_tooltip(f, app); + render_service_meter_tooltip(f, app); render_harness_hover_tooltip(f, app); // A session switch affects the pane's final composited surface, not just // the terminal/chat layer underneath it. Paint transitions after Playbook @@ -4435,22 +4437,36 @@ fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { fn render_token_meter(f: &mut Frame, area: Rect, app: &mut App, now: Instant) { app.layout.matrix_token_graph_area = None; app.token_meter.advance_to(now); - let dim = Style::default().fg(app.theme.dim); + app.layout.matrix_token_graph_area = + render_token_meter_surface(f, area, &app.theme, &app.token_meter); +} + +/// Shared token-meter graph and legend used by the operator, project, and +/// service surfaces. Keeping the complete renderer here makes model colors, +/// grid layout, rate formatting, overflow counts, and the right-aligned sum +/// one visual contract rather than three approximations. +pub(crate) fn render_token_meter_surface( + f: &mut Frame, + area: Rect, + theme: &Theme, + meter: &crate::token_meter::TokenMeter, +) -> Option { + let dim = Style::default().fg(theme.dim); - if app.token_meter.is_idle() { + if meter.is_idle() { // An empty grid is indistinguishable from a broken one. Say why. let msg = "no token usage reported yet"; let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2; let y = area.y + area.height / 2; f.buffer_mut().set_string(x, y, msg, dim); - return; + return None; } // The legend names every series, wrapping onto as many rows of aligned // columns as that takes: a colored bar whose model isn't named anywhere // is unreadable, and on a narrow panel a single row only ever fits one // name. The graph keeps the rest of the panel. - let entries = app.token_meter.legend(area.width as usize); + let entries = meter.legend(area.width as usize); let grid = layout_legend(&entries, area.width as usize, legend_max_rows(area.height)); let legend_h = grid.rows.len() as u16; let graph = Rect { @@ -4460,19 +4476,18 @@ fn render_token_meter(f: &mut Frame, area: Rect, app: &mut App, now: Instant) { height: area.height.saturating_sub(legend_h), }; if graph.height == 0 || graph.width == 0 { - return; + return None; } - app.layout.matrix_token_graph_area = Some(graph); let width = graph.width as usize; - let scale = app.token_meter.scale(width); + let scale = meter.scale(width); let cells = graph.height as usize; let eighths_total = cells * 8; + let history = meter.window(width).count(); - for (col, bucket) in app.token_meter.window(width).enumerate() { + for (col, bucket) in meter.window(width).enumerate() { // History shorter than the panel draws flush right, so the newest // column is always the rightmost one. - let history = app.token_meter.window(width).count(); let x = graph.x + (width - history + col) as u16; let total = bucket.total(); if total == 0 { @@ -4486,22 +4501,23 @@ fn render_token_meter(f: &mut Frame, area: Rect, app: &mut App, now: Instant) { // Paint bottom-up: each band owns a contiguous run of eighths. for cell in crate::token_meter::column_cells(&segments, filled, cells) { let y = graph.y + graph.height.saturating_sub(cell.row + 1); - let mut style = Style::default().fg(app.token_meter.band_color(cell.fg)); + let mut style = Style::default().fg(meter.band_color(cell.fg)); // A terminal cell holds one glyph, so a boundary landing inside // one is drawn as a partial block whose *filled* part is the // lower band and whose background is the upper one. Painting // both as foreground glyphs would mean the second overwrites the // first, and the lower band would vanish from the stack. if let Some(bg) = cell.bg { - style = style.bg(app.token_meter.band_color(bg)); + style = style.bg(meter.band_color(bg)); } f.buffer_mut().set_string(x, y, cell.glyph, style); } } if legend_h > 0 { - render_token_meter_legend(f, area, app, &entries, &grid, legend_h); + render_token_meter_legend(f, area, theme, meter, &entries, &grid, legend_h); } + Some(graph) } /// Rows the bars keep no matter how many series need naming. Three is the @@ -4678,12 +4694,13 @@ fn legend_cell( fn render_token_meter_legend( f: &mut Frame, area: Rect, - app: &mut App, + theme: &Theme, + meter: &crate::token_meter::TokenMeter, entries: &[crate::token_meter::LegendEntry], grid: &LegendGrid, legend_h: u16, ) { - let dim = Style::default().fg(app.theme.dim); + let dim = Style::default().fg(theme.dim); let top = area.y + area.height.saturating_sub(legend_h); let limit = area.x + area.width; let rows = &grid.rows; @@ -4742,10 +4759,10 @@ fn render_token_meter_legend( x += w; } } - // Fleet rate, right-aligned, only if it costs no legend space. Same - // basis as the entries — tokens over compute time — so concurrent + // Summed scoped rate, right-aligned, only if it costs no legend space. + // Same basis as the entries — tokens over compute time — so concurrent // sessions don't make it exceed the sum of the parts. - let sum = format!(" Σ {}", legend_rate(app.token_meter.recent_fleet_rate())); + let sum = format!(" Σ {}", legend_rate(meter.recent_fleet_rate())); let sum_w = UnicodeWidthStr::width(sum.as_str()) as u16; if x + sum_w < limit { f.buffer_mut() @@ -4808,6 +4825,32 @@ fn render_project_meter_tooltip(f: &mut Frame, app: &App) { render_token_meter_hover(f, &app.theme, meter, graph, (mx, my)); } +/// Hover detail for the service view's scoped meter. A split layout can show +/// several services, so resolve the graph under the pointer before selecting +/// its meter. +fn render_service_meter_tooltip(f: &mut Frame, app: &App) { + let Some((mx, my)) = app.mouse_pos else { + return; + }; + let Some((service_name, graph)) = app + .layout + .service_token_graphs + .iter() + .find(|(_, graph)| { + mx >= graph.x + && mx < graph.x.saturating_add(graph.width) + && my >= graph.y + && my < graph.y.saturating_add(graph.height) + }) + else { + return; + }; + let Some(meter) = app.service_token_meters.get(service_name) else { + return; + }; + render_token_meter_hover(f, &app.theme, meter, *graph, (mx, my)); +} + /// Widest a model name gets in the hover detail before it is clipped. Long /// enough for the catalog ids in practice, short enough that one outlier /// can't push the figures off a narrow terminal. @@ -8476,6 +8519,42 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc columns[2], ); + // Keep the service-scoped meter fixed above the scrolling channel/session + // section, matching the project dashboard's graph without making it part + // of keyboard navigation. Short panes give the rows all available space. + let mut section = chunks[2]; + if section.width >= 24 + && section.height >= crate::project_dashboard::METER_HEIGHT.saturating_add(4) + { + let lower = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(crate::project_dashboard::METER_HEIGHT), + Constraint::Length(1), + Constraint::Min(3), + ]) + .split(section); + let meter_area = lower[0]; + section = lower[2]; + let now = Instant::now(); + if let Some(meter) = app.service_token_meters.get_mut(name) { + meter.advance_to(now); + if let Some(graph) = render_token_meter_surface(f, meter_area, &app.theme, meter) { + app.layout + .service_token_graphs + .push((name.to_string(), graph)); + } + } else { + f.render_widget( + Paragraph::new(Span::styled( + " no token usage reported yet for this service ", + Style::default().fg(app.theme.dim), + )), + meter_area, + ); + } + } + let routed: Vec<_> = app .routed_service_sessions(name) .into_iter() @@ -8516,7 +8595,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc push_wrapped_service_line( &mut activity, " No channels in the catalog. Press a or Enter to create an HTTP channel.", - chunks[2].width, + section.width, if selected { Style::default() .fg(app.theme.highlight_fg) @@ -8589,13 +8668,13 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc } channel_rows.push((index, activity.len())); activity.push(Line::from(Span::styled( - truncate_to_width(&row, chunks[2].width as usize), + truncate_to_width(&row, section.width as usize), style, ))); } } if let Some(actions) = selected_channel_actions.as_ref() { - append_service_channel_actions(&mut activity, &mut action_hits, app, chunks[2], actions); + append_service_channel_actions(&mut activity, &mut action_hits, app, section, actions); } activity.push(Line::from("")); activity.push(Line::from(vec![ @@ -8614,7 +8693,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc push_wrapped_service_line( &mut activity, "No requests have created a session yet.", - chunks[2].width, + section.width, Style::default().fg(app.theme.dim), ); } else { @@ -8669,7 +8748,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc push_wrapped_service_line( &mut activity, note, - chunks[2].width, + section.width, Style::default().fg(if dialog.confirm_delete { app.theme.danger } else { @@ -8693,11 +8772,10 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc push_wrapped_service_line( &mut activity, footer, - chunks[2].width, + section.width, Style::default().fg(app.theme.dim), ); - let section = chunks[2]; let viewport_h = section.height as usize; // Every line was built to fit the width, so rows and lines are the same // thing here and the scroll offset means exactly what it says. diff --git a/crates/daemon/src/cost_history.rs b/crates/daemon/src/cost_history.rs index f095f218..9a19d869 100644 --- a/crates/daemon/src/cost_history.rs +++ b/crates/daemon/src/cost_history.rs @@ -93,6 +93,7 @@ mod tests { fn sample(at_ms: i64, tokens: u64) -> TokenSample { TokenSample { at_ms, + session_id: Some("s1".into()), model: Some("opus".into()), tokens, cached: 0, @@ -113,6 +114,10 @@ mod tests { .map(|s| s.at_ms) .collect(); assert_eq!(times, vec![100, 200, 300]); + assert!(history + .recent(WINDOW_SECS, 10, 1_000) + .iter() + .all(|sample| sample.session_id.as_deref() == Some("s1"))); } #[test] diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index affc723d..78a05b55 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1816,6 +1816,7 @@ impl SessionManager { if ts.at >= history_cutoff { cost_samples.push(construct_protocol::TokenSample { at_ms: ts.at.timestamp_millis(), + session_id: Some(s.id.clone()), model: model.clone().or_else(|| scan_model.clone()), // Cached input is a subset of the // prompt side; adding it would diff --git a/crates/daemon/src/session/events.rs b/crates/daemon/src/session/events.rs index 717091aa..3edc97b6 100644 --- a/crates/daemon/src/session/events.rs +++ b/crates/daemon/src/session/events.rs @@ -476,6 +476,7 @@ impl SessionManager { // than being added on top of a prompt side that already // contains it. self.record_cost_sample( + &entry.id, model.clone().or_else(|| s.model.clone()), tokens_in.saturating_add(*tokens_out), *tokens_cached, diff --git a/crates/daemon/src/session/usage_probe.rs b/crates/daemon/src/session/usage_probe.rs index 1a2ad5a6..3125eeef 100644 --- a/crates/daemon/src/session/usage_probe.rs +++ b/crates/daemon/src/session/usage_probe.rs @@ -84,6 +84,7 @@ impl SessionManager { /// Record one usage sample into the fleet history (spec 0167). pub(crate) fn record_cost_sample( &self, + session_id: &str, model: Option, tokens: u64, cached: u64, @@ -93,6 +94,7 @@ impl SessionManager { history.push( construct_protocol::TokenSample { at_ms, + session_id: Some(session_id.to_string()), model, tokens, cached, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index b6fd4a53..c36c0c85 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2536,6 +2536,11 @@ pub struct TokenSample { /// so a client binning these into a history graph places them where they /// actually happened rather than where it happened to learn of them. pub at_ms: i64, + /// Session that reported this usage. Newer daemons always include it; + /// `None` keeps clients compatible with history returned by an older + /// daemon that only supported the fleet-wide meter. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, /// Model this usage is attributed to, already resolved by the daemon: /// the model the report named, else the one the session had in effect at /// that moment. `None` only when the session had never named one. @@ -2571,6 +2576,24 @@ pub struct TokenHistoryResult { pub now_ms: i64, } +#[cfg(test)] +mod token_history_compat_tests { + use super::TokenSample; + + #[test] + fn legacy_samples_without_session_identity_still_decode() { + let sample: TokenSample = serde_json::from_value(serde_json::json!({ + "at_ms": 1, + "model": "opus", + "tokens": 2, + "cached": 1 + })) + .expect("legacy token sample"); + + assert_eq!(sample.session_id, None); + } +} + /// Result of `usage.query`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageQueryResult { diff --git a/specs/0167-fleet-token-meter.md b/specs/0167-fleet-token-meter.md index aafbe385..cc35a099 100644 --- a/specs/0167-fleet-token-meter.md +++ b/specs/0167-fleet-token-meter.md @@ -274,6 +274,14 @@ point in the transcript**, not to the session's current model. A session that switched models must not have its earlier work credited to its later model. +Each retained sample also identifies the reporting session. A client uses +that identity with the currently loaded session summaries to rebuild meters +scoped to a project, service, or other session subset from the same durable +window. Restarting a client must not clear a scoped graph any more than it +clears the fleet graph. History from an older daemon that lacks session +identity can still seed the fleet graph and is omitted only from scoped +graphs, where assigning it would fabricate membership. + ### Coverage is whatever harnesses report Harnesses that report no usage contribute nothing and are not estimated diff --git a/specs/0191-project-dashboard-pane.md b/specs/0191-project-dashboard-pane.md index 4e4e10b2..f1a04e86 100644 --- a/specs/0191-project-dashboard-pane.md +++ b/specs/0191-project-dashboard-pane.md @@ -28,7 +28,8 @@ Top to bottom, dropping lower-priority regions when the pane is short: 2. **Token meter** — project-scoped throughput history fed from the same `Cost` / busy-time path as the fleet meter, filtered to members of this project. Idle projects show a quiet empty line rather than a blank grid. - Hovering a column details it exactly as the fleet meter's does (spec 0167). + Its colored legend follows the operator meter's layout and rate formatting; + hovering a column details it exactly as the fleet meter's does (spec 0167). 3. **Member cards** — a single full-width column, one card per member. ### Members @@ -115,6 +116,9 @@ already carried. args summary (not just call ids) so cards can say what wants approval. - Cost and busy events must feed project-scoped meters even when the project pane is not visible, so switching to a project shows real history. +- Recent token samples identify their reporting session, allowing a starting + client to rebuild each project meter from daemon history. Restarting the TUI + or daemon must not clear the graph for sessions still in that project. - Focus routing (list vs view) is load-bearing: view-focused navigation must not steal list keys while the list holds focus. diff --git a/specs/0196-service-view-token-meter.md b/specs/0196-service-view-token-meter.md new file mode 100644 index 00000000..ac72c3b5 --- /dev/null +++ b/specs/0196-service-view-token-meter.md @@ -0,0 +1,48 @@ +# 0196-service-view-token-meter + +Status: accepted +Date: 2026-08-10 +Area: tui +Scope: A service view shows a live token-usage graph scoped to its routed sessions. + +## Decision + +The service view shows the same token-usage history graph as the project view, +scoped to sessions routed by that service and their descendants. Cost and +compute-time observations feed the service meter even while its view is +closed. Recent token samples identify their reporting session, so a starting +client can rebuild service history from daemon data by applying the same +routed-root and descendant filter used for live events. Opening a service +therefore reveals recent activity even after the TUI or daemon restarts. + +The graph keeps the shared meter semantics: time buckets, stacked model bands, +cache-served shading, and per-column hover detail. Its legend uses the same +colored model names and dots, equal-width column layout, per-model rates, +overflow count, and right-aligned summed rate as the operator-session graph. +Cached input remains a subset of input rather than an additional token count. +When no usage has been observed, the meter region states that quietly. A short +pane omits the graph to preserve usable room for service fields and rows. + +## Reason + +Services can create and reuse many sessions without an operator opening each +one. A scoped history graph answers whether a service is actively consuming +tokens, how that activity changes over time, and which models contribute, +without mixing in unrelated fleet or project work. + +## Consequences + +- Every Cost event from a routed session or descendant contributes to its + service's meter. +- Compute time from routed model-backed sessions and descendants contributes + to the scoped throughput rates. +- Sessions from other services never contribute to the displayed history. +- Split panes may show independent service meters and hover details at once. +- Service meters seed from daemon token history when its samples carry session + identity; samples from older daemons remain fleet-only rather than being + guessed into a service. + +## Non-Goals + +- This does not add an aggregate lifetime token label to the service header. +- This does not change how sessions are associated with a service.