From 62d65b4b9e88f9d6244a1e53c1b8db5577bf4a56 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 10 Aug 2026 19:11:24 -0700 Subject: [PATCH 1/5] feat(tui): show service session token usage --- crates/cli/src/app.rs | 27 +++++++++++++++ crates/cli/src/ui.rs | 15 ++++++-- .../0196-service-view-lifetime-token-usage.md | 34 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 specs/0196-service-view-lifetime-token-usage.md diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 0ef66081..0b7bf1b0 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -42505,7 +42505,30 @@ mod tests { routed.id = "service-session".into(); routed.title = Some("service:assistant:http:demo-conversation".into()); routed.state = construct_protocol::SessionState::AwaitingInput; + routed.tokens = construct_protocol::TokenTally { + input: 120_000, + output: 10_000, + cached: 80_000, + }; 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()); + second.tokens = construct_protocol::TokenTally { + input: 30_000, + output: 3_000, + cached: 20_000, + }; + app.sessions.push(second); + 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()); + other_service.tokens = construct_protocol::TokenTally { + input: 1_000_000, + output: 0, + cached: 0, + }; + app.sessions.push(other_service); app.services.push(service_summary_for_test("assistant")); app.select_service("assistant".into()); app.session_transitions.clear(); @@ -42526,6 +42549,10 @@ mod tests { "rendered service view:\n{text}" ); assert!(text.contains("demo-conversation")); + assert!( + text.contains("2 routed · 163k tok"), + "service lifetime usage should sum every routed session without double-counting cached input:\n{text}" + ); let hit = app .layout diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 6fd0b16c..08be2b9e 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -8481,6 +8481,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc .into_iter() .cloned() .collect(); + let routed_tokens: u64 = routed.iter().map(|session| session.tokens.total()).sum(); let selected_channel_actions = app.selected_service_channel_actions(name); let catalog = app.service_channel_catalog.clone(); let attached_count = catalog @@ -8598,7 +8599,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc append_service_channel_actions(&mut activity, &mut action_hits, app, chunks[2], actions); } activity.push(Line::from("")); - activity.push(Line::from(vec![ + let mut session_heading = vec![ Span::styled( "Sessions ", Style::default() @@ -8609,7 +8610,17 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc format!("{} routed", routed.len()), Style::default().fg(app.theme.text), ), - ])); + ]; + if routed_tokens > 0 { + session_heading.push(Span::styled( + format!( + " · {} tok", + crate::lineage::format_token_count(routed_tokens) + ), + Style::default().fg(app.theme.dim), + )); + } + activity.push(Line::from(session_heading)); if routed.is_empty() { push_wrapped_service_line( &mut activity, diff --git a/specs/0196-service-view-lifetime-token-usage.md b/specs/0196-service-view-lifetime-token-usage.md new file mode 100644 index 00000000..d49a2ad8 --- /dev/null +++ b/specs/0196-service-view-lifetime-token-usage.md @@ -0,0 +1,34 @@ +# 0196-service-view-lifetime-token-usage + +Status: accepted +Date: 2026-08-10 +Area: tui +Scope: A service view reports the aggregate lifetime token usage of its routed sessions. + +## Decision + +The service view's Sessions heading shows the lifetime token total across all +sessions routed by that service whenever the total is non-zero. The total uses +the same token accounting and compact formatting as the project view: input +plus output tokens, with cached input treated as a subset of input rather than +added again. + +## Reason + +Services can create and reuse many sessions without an operator opening each +one. A service-level total makes their aggregate model usage visible at the +same glance as their routed-session count and keeps project and service +organizer views consistent. + +## Consequences + +- Every routed session known to the service view contributes to the total. +- Sessions that have not reported usage contribute zero. +- A zero aggregate is omitted instead of rendering a misleading usage value. +- Future token-accounting changes must keep project and service lifetime + totals aligned. + +## Non-Goals + +- This does not add a service-scoped realtime throughput meter. +- This does not change how sessions are associated with a service. From 5d73fb747bae7ede5e0a82cf3c9eab666ac9ac17 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 10 Aug 2026 19:25:13 -0700 Subject: [PATCH 2/5] feat(tui): add service-scoped token graph --- crates/cli/src/app.rs | 147 +++++++++++++++--- crates/cli/src/app/service_dialog.rs | 35 +++++ crates/cli/src/project_dashboard.rs | 6 +- crates/cli/src/ui.rs | 98 +++++++++--- .../0196-service-view-lifetime-token-usage.md | 34 ---- specs/0196-service-view-token-meter.md | 43 +++++ 6 files changed, 282 insertions(+), 81 deletions(-) delete mode 100644 specs/0196-service-view-lifetime-token-usage.md create mode 100644 specs/0196-service-view-token-meter.md diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 0b7bf1b0..9a2212aa 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| { @@ -5514,6 +5524,7 @@ async fn run_with_socket_initial_selection( 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(), @@ -9688,6 +9699,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 +9716,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 +9739,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 +9753,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,6 +9776,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_busy(model.as_deref(), delta, now); + } } } @@ -16868,6 +16900,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 +17229,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 +18686,39 @@ 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)); + } + /// The picker opens on the model indicator and lists Default plus /// every target, unavailable ones included (spec 0115). #[tokio::test] @@ -42278,6 +42345,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 +42565,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; @@ -42505,31 +42573,39 @@ mod tests { routed.id = "service-session".into(); routed.title = Some("service:assistant:http:demo-conversation".into()); routed.state = construct_protocol::SessionState::AwaitingInput; - routed.tokens = construct_protocol::TokenTally { - input: 120_000, - output: 10_000, - cached: 80_000, - }; 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()); - second.tokens = construct_protocol::TokenTally { - input: 30_000, - output: 3_000, - cached: 20_000, - }; 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()); - other_service.tokens = construct_protocol::TokenTally { - input: 1_000_000, - output: 0, - cached: 0, - }; 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(); @@ -42537,9 +42613,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()) @@ -42550,8 +42625,32 @@ mod tests { ); assert!(text.contains("demo-conversation")); assert!( - text.contains("2 routed · 163k tok"), - "service lifetime usage should sum every routed session without double-counting cached input:\n{text}" + text.contains("170k tok"), + "the graph legend should include routed descendants without double-counting cached input:\n{text}" + ); + assert!(!text.contains("1.0M tok"), "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"); + 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 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..eb1c1903 100644 --- a/crates/cli/src/project_dashboard.rs +++ b/crates/cli/src/project_dashboard.rs @@ -22,7 +22,7 @@ use crate::theme::Theme; use crate::token_meter::{self, TokenMeter}; /// Compact meter height when the pane is tall enough. -const METER_HEIGHT: u16 = 4; +pub(crate) const METER_HEIGHT: u16 = 4; /// Rows kept for member cards even when the meter would like more. const CARDS_MIN_HEIGHT: u16 = 6; @@ -642,7 +642,7 @@ pub fn render( width: w, height: meter_h, }; - meter_graph = render_project_meter(f, meter_area, theme, meter, now); + meter_graph = render_token_meter(f, meter_area, theme, meter, now); row = row.saturating_add(meter_h); } } else if row < bottom { @@ -689,7 +689,7 @@ 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( +pub(crate) fn render_token_meter( f: &mut Frame, area: Rect, theme: &Theme, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 08be2b9e..e03f575c 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 @@ -4808,6 +4810,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,12 +8504,53 @@ 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) = crate::project_dashboard::render_token_meter( + f, + meter_area, + &app.theme, + meter, + now, + ) { + 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() .cloned() .collect(); - let routed_tokens: u64 = routed.iter().map(|session| session.tokens.total()).sum(); let selected_channel_actions = app.selected_service_channel_actions(name); let catalog = app.service_channel_catalog.clone(); let attached_count = catalog @@ -8517,7 +8586,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) @@ -8590,16 +8659,16 @@ 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("")); - let mut session_heading = vec![ + activity.push(Line::from(vec![ Span::styled( "Sessions ", Style::default() @@ -8610,22 +8679,12 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc format!("{} routed", routed.len()), Style::default().fg(app.theme.text), ), - ]; - if routed_tokens > 0 { - session_heading.push(Span::styled( - format!( - " · {} tok", - crate::lineage::format_token_count(routed_tokens) - ), - Style::default().fg(app.theme.dim), - )); - } - activity.push(Line::from(session_heading)); + ])); if routed.is_empty() { 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 { @@ -8680,7 +8739,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 { @@ -8704,11 +8763,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/specs/0196-service-view-lifetime-token-usage.md b/specs/0196-service-view-lifetime-token-usage.md deleted file mode 100644 index d49a2ad8..00000000 --- a/specs/0196-service-view-lifetime-token-usage.md +++ /dev/null @@ -1,34 +0,0 @@ -# 0196-service-view-lifetime-token-usage - -Status: accepted -Date: 2026-08-10 -Area: tui -Scope: A service view reports the aggregate lifetime token usage of its routed sessions. - -## Decision - -The service view's Sessions heading shows the lifetime token total across all -sessions routed by that service whenever the total is non-zero. The total uses -the same token accounting and compact formatting as the project view: input -plus output tokens, with cached input treated as a subset of input rather than -added again. - -## Reason - -Services can create and reuse many sessions without an operator opening each -one. A service-level total makes their aggregate model usage visible at the -same glance as their routed-session count and keeps project and service -organizer views consistent. - -## Consequences - -- Every routed session known to the service view contributes to the total. -- Sessions that have not reported usage contribute zero. -- A zero aggregate is omitted instead of rendering a misleading usage value. -- Future token-accounting changes must keep project and service lifetime - totals aligned. - -## Non-Goals - -- This does not add a service-scoped realtime throughput meter. -- This does not change how sessions are associated with a service. diff --git a/specs/0196-service-view-token-meter.md b/specs/0196-service-view-token-meter.md new file mode 100644 index 00000000..d35306ee --- /dev/null +++ b/specs/0196-service-view-token-meter.md @@ -0,0 +1,43 @@ +# 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, so opening the service reveals activity the TUI has already observed. + +The graph keeps the shared meter semantics: time buckets, stacked model bands, +cache-served shading, compact totals and rates, and per-column hover detail. +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 begin with observations made by the current TUI process; + daemon token-history samples cannot seed them because those samples do not + carry session identity. + +## 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. From 2e9cb60684b58606739f1091ada557af8414a479 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 10 Aug 2026 19:34:50 -0700 Subject: [PATCH 3/5] fix(tui): unify scoped token meter legends --- crates/cli/src/app.rs | 19 ++++- crates/cli/src/project_dashboard.rs | 103 +++++-------------------- crates/cli/src/token_meter.rs | 3 +- crates/cli/src/ui.rs | 57 ++++++++------ specs/0191-project-dashboard-pane.md | 3 +- specs/0196-service-view-token-meter.md | 4 +- 6 files changed, 74 insertions(+), 115 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 9a2212aa..d5e488ac 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -42625,10 +42625,10 @@ mod tests { ); assert!(text.contains("demo-conversation")); assert!( - text.contains("170k tok"), - "the graph legend should include routed descendants without double-counting cached input:\n{text}" + 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("1.0M tok"), "another service leaked in:\n{text}"); + assert!(!text.contains("reviewer-model"), "another service leaked in:\n{text}"); let (meter_service, graph) = app .layout .service_token_graphs @@ -42636,6 +42636,19 @@ mod tests { .cloned() .expect("the service view should paint a token graph"); assert_eq!(meter_service, "assistant"); + 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)| { diff --git a/crates/cli/src/project_dashboard.rs b/crates/cli/src/project_dashboard.rs index eb1c1903..8a3528c0 100644 --- a/crates/cli/src/project_dashboard.rs +++ b/crates/cli/src/project_dashboard.rs @@ -19,7 +19,7 @@ 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. pub(crate) const METER_HEIGHT: u16 = 4; @@ -642,7 +642,7 @@ pub fn render( width: w, height: meter_h, }; - meter_graph = render_token_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 +687,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). -pub(crate) fn render_token_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,16 +1127,17 @@ 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:?}"); @@ -1225,6 +1145,19 @@ mod tests { 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 e03f575c..844940a5 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -4437,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); +} - if app.token_meter.is_idle() { +/// 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 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 { @@ -4462,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 { @@ -4488,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 @@ -4680,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; @@ -4744,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() @@ -8524,13 +8539,7 @@ fn render_service_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, foc let now = Instant::now(); if let Some(meter) = app.service_token_meters.get_mut(name) { meter.advance_to(now); - if let Some(graph) = crate::project_dashboard::render_token_meter( - f, - meter_area, - &app.theme, - meter, - 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)); diff --git a/specs/0191-project-dashboard-pane.md b/specs/0191-project-dashboard-pane.md index 4e4e10b2..c9ef106b 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 diff --git a/specs/0196-service-view-token-meter.md b/specs/0196-service-view-token-meter.md index d35306ee..64172a9f 100644 --- a/specs/0196-service-view-token-meter.md +++ b/specs/0196-service-view-token-meter.md @@ -13,7 +13,9 @@ compute-time observations feed the service meter even while its view is closed, so opening the service reveals activity the TUI has already observed. The graph keeps the shared meter semantics: time buckets, stacked model bands, -cache-served shading, compact totals and rates, and per-column hover detail. +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. From 0b83e6c019de9420342fc509efa74d1d78839567 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 10 Aug 2026 19:41:44 -0700 Subject: [PATCH 4/5] tweak(tui): increase scoped token graph height --- crates/cli/src/app.rs | 5 +++++ crates/cli/src/project_dashboard.rs | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index d5e488ac..e1416825 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -42636,6 +42636,11 @@ mod tests { .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; diff --git a/crates/cli/src/project_dashboard.rs b/crates/cli/src/project_dashboard.rs index 8a3528c0..3c2a4e4d 100644 --- a/crates/cli/src/project_dashboard.rs +++ b/crates/cli/src/project_dashboard.rs @@ -21,8 +21,10 @@ use unicode_width::UnicodeWidthStr; use crate::theme::Theme; use crate::token_meter::TokenMeter; -/// Compact meter height when the pane is tall enough. -pub(crate) 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; @@ -1141,6 +1143,7 @@ mod tests { 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:?}" From 0bbee932d7ee273a864ec38af6786bb8630690bb Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 10 Aug 2026 19:51:08 -0700 Subject: [PATCH 5/5] fix(tui): restore scoped token graph history --- crates/cli/src/app.rs | 144 +++++++++++++++++++++-- crates/daemon/src/cost_history.rs | 5 + crates/daemon/src/session.rs | 1 + crates/daemon/src/session/events.rs | 1 + crates/daemon/src/session/usage_probe.rs | 2 + crates/protocol/src/lib.rs | 23 ++++ specs/0167-fleet-token-meter.md | 8 ++ specs/0191-project-dashboard-pane.md | 3 + specs/0196-service-view-token-meter.md | 11 +- 9 files changed, 185 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index e1416825..77baeae8 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -5510,15 +5510,22 @@ 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 @@ -5541,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); } @@ -9667,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 @@ -9785,6 +9847,12 @@ impl App { } } + /// 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 @@ -18719,6 +18787,64 @@ mod tests { 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] 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 c9ef106b..f1a04e86 100644 --- a/specs/0191-project-dashboard-pane.md +++ b/specs/0191-project-dashboard-pane.md @@ -116,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 index 64172a9f..ac72c3b5 100644 --- a/specs/0196-service-view-token-meter.md +++ b/specs/0196-service-view-token-meter.md @@ -10,7 +10,10 @@ Scope: A service view shows a live token-usage graph scoped to its routed sessio 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, so opening the service reveals activity the TUI has already observed. +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 @@ -35,9 +38,9 @@ without mixing in unrelated fleet or project work. 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 begin with observations made by the current TUI process; - daemon token-history samples cannot seed them because those samples do not - carry session identity. +- 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