From cbb267f6b024c501bb66ab94fb4a981c6e8a9580 Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:06:42 +0800 Subject: [PATCH 1/4] fix(tui): confine group-by scope and pin canonical model identity - pin Daily/Period top-model rankings to bare canonical model IDs - add explicit model_id/workspace fields to daily/hourly model info - show workspace as its own column in Workspace+Model grouping - gate the group-by picker to tabs that consume grouped projections - decouple session refresh from group-by reloads - make model shade resolution grouping-invariant - make TUI export self-describing (groupBy, workspaceKey/workspaceLabel) - record the view-scope contract in ADR 0026 --- crates/tokscale-cli/src/tui/app.rs | 216 ++++++++++--- crates/tokscale-cli/src/tui/cache.rs | 125 +++++++- crates/tokscale-cli/src/tui/colors.rs | 110 +++++-- crates/tokscale-cli/src/tui/data/mod.rs | 3 + crates/tokscale-cli/src/tui/export.rs | 102 +++++- crates/tokscale-cli/src/tui/mod.rs | 45 ++- crates/tokscale-cli/src/tui/ui/daily.rs | 258 +++++++++++++-- crates/tokscale-cli/src/tui/ui/footer.rs | 8 +- crates/tokscale-cli/src/tui/ui/hourly.rs | 87 ++++- .../src/tui/ui/model_usage_layout.rs | 85 +++-- crates/tokscale-cli/src/tui/ui/models.rs | 190 +++++++++-- crates/tokscale-cli/src/tui/ui/overview.rs | 40 +-- .../src/tui/ui/overview_snapshot.rs | 11 +- crates/tokscale-cli/src/tui/ui/period.rs | 300 +++++++++++++++++- crates/tokscale-cli/src/tui/ui/stats.rs | 64 +++- crates/tokscale-cli/src/tui/ui/widgets.rs | 6 + .../src/aggregate/parity_tests.rs | 10 +- crates/tokscale-core/src/aggregate/tui.rs | 174 +++++++--- crates/tokscale-core/src/usage_views.rs | 10 + docs/adr/0026-group-by-view-scope-contract.md | 82 +++++ 20 files changed, 1657 insertions(+), 269 deletions(-) create mode 100644 docs/adr/0026-group-by-view-scope-contract.md diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index c53c66bef..522501fe7 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -16,7 +16,7 @@ use tokscale_core::{ use ratatui::style::Color; -use super::colors::{get_model_color, get_provider_shade, provider_color_key}; +use super::colors::{get_provider_shade, provider_color_key}; use super::data::{ build_period_usage, AgentUsage, DailySourceInfo, DailyUsage, DataLoader, HourlyUsage, ModelUsage, PeriodKind, PeriodUsage, TokenBreakdown, UsageData, @@ -239,6 +239,9 @@ pub struct DetailRow { pub provider: String, pub model: String, pub color_key: String, + /// Workspace dimension for the Workspace column; populated only from + /// `DailyModelInfo` workspace fields (i.e. under `GroupBy::WorkspaceModel`). + pub workspace: Option, pub tokens: TokenBreakdown, pub cost: f64, pub messages: u64, @@ -267,6 +270,7 @@ struct DetailRowAccumulator { provider: String, model: String, color_key: String, + workspace: Option, tokens: TokenBreakdown, cost: f64, messages: u64, @@ -320,6 +324,10 @@ fn build_detail_rows(source_breakdown: &BTreeMap) -> Ve }, // Merged detail buckets share a model-derived color key. color_key: model_info.color_key.clone(), + workspace: model_info + .workspace_label + .clone() + .or_else(|| model_info.workspace_key.clone()), tokens: TokenBreakdown::default(), cost: 0.0, messages: 0, @@ -351,6 +359,7 @@ fn build_detail_rows(source_breakdown: &BTreeMap) -> Ve provider: row.provider, model: row.model, color_key: row.color_key, + workspace: row.workspace, tokens: row.tokens, cost: row.cost, messages: row.messages, @@ -449,6 +458,11 @@ pub struct App { /// (manual refresh and filter changes must always re-aggregate). pub reload_force: bool, + /// Marks the pending reload as grouping-triggered. Grouping is a + /// view-scope projection (ADR 0026), so such reloads re-aggregate but + /// must not force a Sessions rescan; only source-digest changes may. + pub reload_group_only: bool, + /// Digest of the scanned sources at the last completed load; auto-refresh /// skips the parse when a fresh probe matches (ADR 0008). pub last_source_digest: Option, @@ -457,10 +471,20 @@ pub struct App { pub dialog_needs_reload: Rc>, + /// Reload channel for the Group By picker, kept separate from + /// `dialog_needs_reload` so grouping reloads stay distinguishable from + /// source-filter reloads. + pub dialog_group_reload: Rc>, + pub hourly_view_mode: HourlyViewMode, pub model_shade_map: HashMap, + /// Canonical model -> resolved provider key. Color lookups resolve the + /// provider through this map so one canonical model gets the same shade + /// under every `GroupBy` projection (ADR 0026). + pub model_provider_map: HashMap, + pub subscription_usage: Vec, pub subscription_usage_errors: Vec, subscription_provider_ids: Vec, @@ -537,6 +561,7 @@ impl App { let has_data = !data.models.is_empty(); let dialog_stack = DialogStack::new(theme.clone()); let dialog_needs_reload = Rc::new(RefCell::new(false)); + let dialog_group_reload = Rc::new(RefCell::new(false)); let requested_tab = config.initial_tab.unwrap_or(Tab::Overview); if !Self::tab_visible(&settings, requested_tab) { anyhow::bail!( @@ -599,11 +624,14 @@ impl App { blocking_loading: false, needs_reload: false, reload_force: false, + reload_group_only: false, last_source_digest: None, dialog_stack, dialog_needs_reload, + dialog_group_reload, hourly_view_mode: HourlyViewMode::default(), model_shade_map: HashMap::new(), + model_provider_map: HashMap::new(), subscription_usage: if usage_tab_enabled { #[cfg(not(test))] { @@ -655,22 +683,22 @@ impl App { pub fn is_blocking_loading(&self) -> bool { self.blocking_loading - || (!self.dialog_stack.is_active() && *self.dialog_needs_reload.borrow()) + || (!self.dialog_stack.is_active() + && (*self.dialog_needs_reload.borrow() || *self.dialog_group_reload.borrow())) } fn consume_dialog_reload_if_ready(&mut self) { - let needs_blocking_reload = { - let mut needs_reload = self.dialog_needs_reload.borrow_mut(); - if !self.dialog_stack.is_active() && *needs_reload { - *needs_reload = false; - true - } else { - false - } - }; + if self.dialog_stack.is_active() { + return; + } + let source_reload = std::mem::take(&mut *self.dialog_needs_reload.borrow_mut()); + let group_reload = std::mem::take(&mut *self.dialog_group_reload.borrow_mut()); - if needs_blocking_reload { + if source_reload || group_reload { self.request_blocking_reload(); + // Grouping is a view-scope projection (ADR 0026): a reload it + // triggers must not force a Sessions rescan. + self.reload_group_only = group_reload && !source_reload; } } @@ -761,11 +789,23 @@ impl App { } pub fn build_model_shade_map(&mut self) { - self.model_shade_map = super::colors::build_model_shade_map(&self.data.models); + let built = super::colors::build_model_shade_map(&self.data.models); + self.model_shade_map = built.shades; + self.model_provider_map = built.providers; + } + + /// Resolves the provider for color lookups to the canonical per-model + /// provider, falling back to the caller-supplied provider's first segment + /// for models outside the current data set. + fn resolve_color_provider<'a>(&'a self, provider: &'a str, model: &str) -> &'a str { + self.model_provider_map + .get(model) + .map(String::as_str) + .unwrap_or_else(|| provider_color_key(provider)) } pub fn model_color_for(&self, provider: &str, model: &str) -> Color { - let provider = provider_color_key(provider); + let provider = self.resolve_color_provider(provider, model); let lookup_key = super::colors::model_shade_key(provider, model); let color = self .model_shade_map @@ -776,14 +816,7 @@ impl App { } pub fn model_color(&self, model: &str) -> Color { - let provider = provider_color_key(""); - let lookup_key = super::colors::model_shade_key(provider, model); - let color = self - .model_shade_map - .get(&lookup_key) - .copied() - .unwrap_or_else(|| get_model_color(model)); - self.theme.color(color) + self.model_color_for("", model) } pub fn has_visible_data(&self) -> bool { @@ -1003,7 +1036,14 @@ impl App { }; self.reset_hourly_view_interaction(); } - KeyCode::Char('g') => { + // Group By only reshapes the group-keyed projections (ADR 0026): + // Models plus the Daily/Monthly/Weekly tables built from them. + KeyCode::Char('g') + if matches!( + self.current_tab, + Tab::Models | Tab::Daily | Tab::Monthly | Tab::Weekly + ) => + { self.open_group_by_picker(); } KeyCode::Char('u') if self.current_tab == Tab::Usage => { @@ -1663,7 +1703,7 @@ impl App { fn open_group_by_picker(&mut self) { use super::ui::dialog::GroupByPickerDialog; let dialog = - GroupByPickerDialog::new(self.group_by.clone(), self.dialog_needs_reload.clone()); + GroupByPickerDialog::new(self.group_by.clone(), self.dialog_group_reload.clone()); self.dialog_stack.show(Box::new(dialog)); } @@ -1938,8 +1978,9 @@ impl App { ); let export_dir = crate::paths::get_config_dir().join("exports"); let path = export_dir.join(filename); + let group_by = self.group_by.borrow().clone(); - match super::export::build_export_json(&self.data) { + match super::export::build_export_json(&self.data, &group_by) { Ok(json) => match std::fs::create_dir_all(&export_dir) .and_then(|_| std::fs::write(&path, json)) { @@ -2668,8 +2709,11 @@ mod tests { model.to_string(), DailyModelInfo { provider: provider.to_string(), + model_id: model.to_string(), display_name: model.to_string(), color_key: model.to_string(), + workspace_key: None, + workspace_label: None, tokens, cost: model_cost, messages: 1, @@ -3927,6 +3971,7 @@ mod tests { #[test] fn test_group_by_change_requests_blocking_reload() { let mut app = make_app(); + app.current_tab = Tab::Models; *app.group_by.borrow_mut() = tokscale_core::GroupBy::ClientModel; app.handle_key_event(key(KeyCode::Char('g'))); @@ -3942,6 +3987,55 @@ mod tests { assert!(app.needs_reload); assert!(app.blocking_loading); assert!(app.is_blocking_loading()); + // Grouping reloads re-aggregate but must not force a Sessions rescan. + assert!(app.reload_group_only); + } + + #[test] + fn test_source_picker_reload_is_not_marked_group_only() { + let mut app = make_app(); + + app.handle_key_event(key(KeyCode::Char('s'))); + app.handle_key_event(key(KeyCode::Enter)); + app.handle_key_event(key(KeyCode::Esc)); + + assert!(app.needs_reload); + assert!(app.reload_force); + assert!(!app.reload_group_only); + } + + #[test] + fn test_g_opens_group_picker_only_on_group_keyed_tabs() { + for tab in [Tab::Models, Tab::Daily, Tab::Monthly, Tab::Weekly] { + let mut app = make_app(); + app.current_tab = tab; + + app.handle_key_event(key(KeyCode::Char('g'))); + + assert!( + app.dialog_stack.is_active(), + "g should open the Group By picker on {tab:?}" + ); + } + + for tab in [ + Tab::Overview, + Tab::Usage, + Tab::Hourly, + Tab::Stats, + Tab::Agents, + Tab::Sessions, + ] { + let mut app = make_app(); + app.current_tab = tab; + + app.handle_key_event(key(KeyCode::Char('g'))); + + assert!( + !app.dialog_stack.is_active(), + "g should be a no-op on {tab:?}" + ); + } } #[test] @@ -4891,7 +4985,10 @@ mod tests { } #[test] - fn test_same_model_name_keeps_distinct_provider_colors() { + fn test_same_canonical_model_resolves_one_color_across_providers() { + // A canonical model seen at several providers resolves to one + // deterministic provider, so every grouping projection renders it + // with the same shade (ADR 0026). let mut app = make_app(); app.data.models = vec![ ModelUsage { @@ -4919,17 +5016,64 @@ mod tests { ]; app.build_model_shade_map(); + let canonical = app.theme.color(get_provider_shade("anthropic", 0)); + assert_eq!(app.model_color_for("anthropic", "sonnet-shared"), canonical); + assert_eq!(app.model_color_for("openai", "sonnet-shared"), canonical); + assert_eq!(app.model_color("sonnet-shared"), canonical); + } + + #[test] + fn test_model_color_is_identical_for_merged_and_split_projections() { + // GroupBy::Model merges providers into one entry; the other groupings + // split the same messages into per-bucket entries. Both projections + // must yield the same color for a canonical model. + let merged_entry = ModelUsage { + model: "sonnet-shared".to_string(), + provider: "anthropic, openai".to_string(), + client: "claude, codex".to_string(), + workspace_key: None, + workspace_label: None, + tokens: TokenBreakdown::default(), + cost: 15.0, + performance: Default::default(), + session_count: 2, + }; + let mut merged = make_app(); + merged.data.models = vec![merged_entry]; + merged.build_model_shade_map(); + + let mut split = make_app(); + split.data.models = vec![ + ModelUsage { + model: "sonnet-shared".to_string(), + provider: "anthropic".to_string(), + client: "claude".to_string(), + workspace_key: None, + workspace_label: None, + tokens: TokenBreakdown::default(), + cost: 10.0, + performance: Default::default(), + session_count: 1, + }, + ModelUsage { + model: "sonnet-shared".to_string(), + provider: "openai".to_string(), + client: "codex".to_string(), + workspace_key: None, + workspace_label: None, + tokens: TokenBreakdown::default(), + cost: 5.0, + performance: Default::default(), + session_count: 1, + }, + ]; + split.build_model_shade_map(); + + assert_eq!(merged.model_shade_map, split.model_shade_map); + assert_eq!(merged.model_provider_map, split.model_provider_map); assert_eq!( - app.model_color_for("anthropic", "sonnet-shared"), - app.theme.color(get_provider_shade("anthropic", 0)) - ); - assert_eq!( - app.model_color_for("openai", "sonnet-shared"), - app.theme.color(get_provider_shade("openai", 0)) - ); - assert_ne!( - app.model_color_for("anthropic", "sonnet-shared"), - app.model_color_for("openai", "sonnet-shared") + merged.model_color("sonnet-shared"), + split.model_color("sonnet-shared") ); } } diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index 04ae63b61..c30af8e52 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -23,7 +23,7 @@ use super::data::{ /// Cache staleness threshold: 5 minutes (matches TS implementation) const CACHE_STALE_THRESHOLD_MS: u64 = 5 * 60 * 1000; -const CACHE_SCHEMA_VERSION: u32 = 37; +const CACHE_SCHEMA_VERSION: u32 = 38; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -181,8 +181,14 @@ struct CachedAgentUsage { #[serde(rename_all = "camelCase")] struct CachedDailyModelInfo { provider: String, + #[serde(default)] + model_id: String, display_name: String, color_key: String, + #[serde(default)] + workspace_key: Option, + #[serde(default)] + workspace_label: Option, tokens: CachedTokenBreakdown, cost: f64, messages: u64, @@ -211,6 +217,8 @@ struct CachedDailyUsage { #[serde(rename_all = "camelCase")] struct CachedHourlyModelInfo { provider: String, + #[serde(default)] + model_id: String, display_name: String, color_key: String, tokens: CachedTokenBreakdown, @@ -405,8 +413,11 @@ impl<'a> From<&'a AgentUsage> for CachedAgentUsageRef<'a> { #[serde(rename_all = "camelCase")] struct CachedDailyModelInfoRef<'a> { provider: &'a str, + model_id: &'a str, display_name: &'a str, color_key: &'a str, + workspace_key: Option<&'a str>, + workspace_label: Option<&'a str>, tokens: CachedTokenBreakdownRef, cost: f64, messages: u64, @@ -416,8 +427,11 @@ impl<'a> From<&'a DailyModelInfo> for CachedDailyModelInfoRef<'a> { fn from(model: &'a DailyModelInfo) -> Self { Self { provider: &model.provider, + model_id: &model.model_id, display_name: &model.display_name, color_key: &model.color_key, + workspace_key: model.workspace_key.as_deref(), + workspace_label: model.workspace_label.as_deref(), tokens: (&model.tokens).into(), cost: model.cost, messages: model.messages, @@ -523,6 +537,7 @@ impl Serialize for CachedDailyEntriesRef<'_> { #[serde(rename_all = "camelCase")] struct CachedHourlyModelInfoRef<'a> { provider: &'a str, + model_id: &'a str, display_name: &'a str, color_key: &'a str, tokens: CachedTokenBreakdownRef, @@ -533,6 +548,7 @@ impl<'a> From<&'a HourlyModelInfo> for CachedHourlyModelInfoRef<'a> { fn from(model: &'a HourlyModelInfo) -> Self { Self { provider: &model.provider, + model_id: &model.model_id, display_name: &model.display_name, color_key: &model.color_key, tokens: (&model.tokens).into(), @@ -709,8 +725,11 @@ impl From for AgentUsage { fn daily_model_info_from_cached(value: CachedDailyModelInfo) -> DailyModelInfo { DailyModelInfo { provider: value.provider, + model_id: value.model_id, display_name: value.display_name, color_key: value.color_key, + workspace_key: value.workspace_key, + workspace_label: value.workspace_label, tokens: value.tokens.into(), cost: value.cost, messages: value.messages, @@ -737,6 +756,7 @@ impl From for DailySourceInfo { fn hourly_model_info_from_cached(value: CachedHourlyModelInfo) -> HourlyModelInfo { HourlyModelInfo { provider: value.provider, + model_id: value.model_id, display_name: value.display_name, color_key: value.color_key, tokens: value.tokens.into(), @@ -1278,8 +1298,11 @@ mod tests { "zeta-model".to_string(), DailyModelInfo { provider: "anthropic".to_string(), + model_id: "zeta-model".to_string(), display_name: "Zeta Model".to_string(), color_key: "zeta-model".to_string(), + workspace_key: None, + workspace_label: None, tokens: token_breakdown(31), cost: 3.1, messages: 7, @@ -1289,8 +1312,11 @@ mod tests { "alpha-model".to_string(), DailyModelInfo { provider: "openai".to_string(), + model_id: "alpha-model".to_string(), display_name: "Alpha Model".to_string(), color_key: "alpha-model".to_string(), + workspace_key: Some("/repo-alpha".to_string()), + workspace_label: Some("repo-alpha".to_string()), tokens: token_breakdown(32), cost: 3.2, messages: 8, @@ -1302,8 +1328,11 @@ mod tests { "gemini-model".to_string(), DailyModelInfo { provider: "google".to_string(), + model_id: "gemini-model".to_string(), display_name: "Gemini Model".to_string(), color_key: "gemini-model".to_string(), + workspace_key: None, + workspace_label: None, tokens: token_breakdown(33), cost: 3.3, messages: 9, @@ -1332,6 +1361,7 @@ mod tests { "zeta-model".to_string(), HourlyModelInfo { provider: "anthropic".to_string(), + model_id: "zeta-model".to_string(), display_name: "Zeta Model".to_string(), color_key: "zeta-model".to_string(), tokens: token_breakdown(51), @@ -1342,6 +1372,7 @@ mod tests { "alpha-model".to_string(), HourlyModelInfo { provider: "openai".to_string(), + model_id: "alpha-model".to_string(), display_name: "Alpha Model".to_string(), color_key: "alpha-model".to_string(), tokens: token_breakdown(52), @@ -1585,8 +1616,11 @@ mod tests { .keys(), vec![ "provider", + "modelId", "displayName", "colorKey", + "workspaceKey", + "workspaceLabel", "tokens", "cost", "messages", @@ -1608,7 +1642,14 @@ mod tests { ); assert_eq!( ordered_hourly.field("models").element(0).element(1).keys(), - vec!["provider", "displayName", "colorKey", "tokens", "cost"] + vec![ + "provider", + "modelId", + "displayName", + "colorKey", + "tokens", + "cost" + ] ); let ordered_graph = ordered_data.field("graph"); @@ -1927,6 +1968,86 @@ mod tests { } } + #[test] + #[serial] + fn test_load_cache_misses_for_pre_identity_schema() { + let temp_dir = TempDir::new().unwrap(); + let previous_home = env::var_os("HOME"); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + // Schema 37 predates the model identity fields (modelId, + // workspaceKey, workspaceLabel on daily/hourly models). The file must + // still deserialize, then miss on the schema version check — no parse + // error, no partial data (ADR 0026). + let cache_path = cache_file().unwrap(); + fs::create_dir_all(cache_path.parent().unwrap()).unwrap(); + fs::write( + &cache_path, + r#"{ + "schemaVersion": 37, + "timestamp": 9999999999999, + "enabledClients": ["claude"], + "groupBy": "model", + "reportScope": { + "resolvedHomeDir": "", + "useEnvRoots": false, + "since": null, + "until": null, + "year": null + }, + "sourceInventorySignature": [90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90], + "data": { + "models": [], + "agents": [], + "daily": [{ + "date": "2026-07-11", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "sourceBreakdown": [["claude", { + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "models": [["v1|m|5:model", { + "provider": "anthropic", + "displayName": "model", + "colorKey": "model", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "messages": 1 + }]] + }]], + "messageCount": 1, + "turnCount": 1 + }], + "hourly": [], + "graph": null, + "totalTokens": 1, + "totalCost": 0.1, + "currentStreak": 1, + "longestStreak": 1 + } +}"#, + ) + .unwrap(); + + let raw = fs::read(&cache_path).unwrap(); + let parsed: CachedTUIData = + serde_json::from_slice(&raw).expect("legacy schema file must still deserialize"); + assert_eq!(parsed.schema_version, 37); + + let clients = make_filters(&[ClientId::Claude]); + assert!(matches!( + load_cache(&clients, &GroupBy::Model, &CacheReportScope::default()), + CacheResult::Miss + )); + + match previous_home { + Some(home) => unsafe { env::set_var("HOME", home) }, + None => unsafe { env::remove_var("HOME") }, + } + } + #[test] #[serial] fn test_load_cache_misses_when_report_scope_differs() { diff --git a/crates/tokscale-cli/src/tui/colors.rs b/crates/tokscale-cli/src/tui/colors.rs index 1900dd95d..c91d641c8 100644 --- a/crates/tokscale-cli/src/tui/colors.rs +++ b/crates/tokscale-cli/src/tui/colors.rs @@ -21,10 +21,6 @@ fn parse_catalog_color(hex: &str) -> Color { parse_hex_color(hex).expect("client catalog colors are validated as #RRGGBB") } -pub fn get_model_color(_model: &str) -> Color { - get_provider_shade("unknown", 0) -} - /// Returns the shade for a given `(provider, rank)` pair. /// Honors `[colors.providers]` config overrides at every rank by deriving /// a 7-step lighten-to-white palette from the override base color. @@ -168,38 +164,72 @@ pub fn model_shade_key(provider: &str, model: &str) -> String { format!("{provider}\0{model}") } +/// Color assignment for the model charts: per-`(provider, model)` shades plus +/// the resolved provider for every canonical model. +#[derive(Debug, Default)] +pub struct ModelShadeMap { + pub shades: HashMap, + pub providers: HashMap, +} + /// Builds a `(provider, model) -> Color` map where each provider's models are /// cost-ranked; rank 0 (highest cost) gets the base provider color and later /// ranks get progressively lighter shades. /// -/// Aggregates cost per (provider, model) so the same model appearing in -/// multiple group-by buckets (e.g. `GroupBy::WorkspaceModel`) doesn't inflate -/// the rank count. Ties on cost are resolved by model name so shade assignment -/// stays deterministic across refreshes. -pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap { - let mut by_provider: HashMap<&str, HashMap<&str, f64>> = HashMap::new(); +/// Aggregation is canonical (ADR 0026): cost is summed per bare model id, and +/// each model resolves to one deterministic provider — the lexicographically +/// smallest provider color key across its entries, which matches the first +/// segment of the sorted merged-provider string a `GroupBy::Model` projection +/// produces. The same model therefore gets the same shade no matter how the +/// active grouping buckets `UsageData.models` (e.g. `ClientProviderModel` +/// splitting providers, `WorkspaceModel` repeating rows per workspace). Ties +/// on cost are resolved by model name so shade assignment stays deterministic +/// across refreshes. +pub fn build_model_shade_map(models: &[ModelUsage]) -> ModelShadeMap { + let mut cost_by_model: HashMap<&str, f64> = HashMap::new(); + let mut provider_by_model: HashMap<&str, &str> = HashMap::new(); for m in models { let provider = provider_color_key(&m.provider); let cost = if m.cost.is_finite() { m.cost } else { 0.0 }; - *by_provider + *cost_by_model.entry(m.model.as_str()).or_insert(0.0) += cost; + provider_by_model + .entry(m.model.as_str()) + .and_modify(|current| { + if provider < *current { + *current = provider; + } + }) + .or_insert(provider); + } + + let mut by_provider: HashMap<&str, Vec<(&str, f64)>> = HashMap::new(); + for (model, cost) in &cost_by_model { + let provider = provider_by_model[model]; + by_provider .entry(provider) .or_default() - .entry(m.model.as_str()) - .or_insert(0.0) += cost; + .push((*model, *cost)); } - let mut map = HashMap::new(); + let mut shades = HashMap::new(); for (provider, models_map) in by_provider { - let mut ranked: Vec<(&str, f64)> = models_map.into_iter().collect(); + let mut ranked = models_map; ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0))); for (rank, (name, _)) in ranked.iter().enumerate() { - map.insert( + shades.insert( model_shade_key(provider, name), get_provider_shade(provider, rank), ); } } - map + + ModelShadeMap { + shades, + providers: provider_by_model + .into_iter() + .map(|(model, provider)| (model.to_string(), provider.to_string())) + .collect(), + } } #[cfg(test)] @@ -226,8 +256,8 @@ mod tests { TokscaleConfig::initialize_default_for_tests(); let map = build_model_shade_map(&[model_usage("", "u2")]); - assert!(map.contains_key(&model_shade_key("unknown", "u2"))); - assert!(!map.contains_key(&model_shade_key("unisound", "u2"))); + assert!(map.shades.contains_key(&model_shade_key("unknown", "u2"))); + assert!(!map.shades.contains_key(&model_shade_key("unisound", "u2"))); } #[test] @@ -235,8 +265,46 @@ mod tests { TokscaleConfig::initialize_default_for_tests(); let map = build_model_shade_map(&[model_usage("openai, anthropic", "shared-model")]); - assert!(map.contains_key(&model_shade_key("openai", "shared-model"))); - assert!(!map.contains_key(&model_shade_key("anthropic", "shared-model"))); + assert!(map + .shades + .contains_key(&model_shade_key("openai", "shared-model"))); + assert!(!map + .shades + .contains_key(&model_shade_key("anthropic", "shared-model"))); + } + + #[test] + fn shade_map_is_grouping_invariant_for_split_provider_buckets() { + TokscaleConfig::initialize_default_for_tests(); + // GroupBy::Model shape: one entry per model with the sorted + // merged-provider string and the total cost. + let merged = build_model_shade_map(&[ + model_usage("anthropic, openai", "shared-model"), + model_usage("openai", "openai-only"), + ]); + // GroupBy::ClientProviderModel shape: the same messages bucketed per + // provider, so costs are split across entries. + let mut split_entries = vec![ + model_usage("anthropic", "shared-model"), + model_usage("openai", "shared-model"), + model_usage("openai", "openai-only"), + ]; + split_entries[0].cost = 0.6; + split_entries[1].cost = 0.4; + let split = build_model_shade_map(&split_entries); + + assert_eq!(merged.shades, split.shades); + assert_eq!(merged.providers, split.providers); + assert_eq!( + split.providers.get("shared-model").map(String::as_str), + Some("anthropic") + ); + // Costs are summed per canonical model, so ranks match the merged + // projection instead of the per-bucket splits. + assert_eq!( + split.shades.get(&model_shade_key("openai", "openai-only")), + Some(&get_provider_shade("openai", 0)) + ); } #[test] diff --git a/crates/tokscale-cli/src/tui/data/mod.rs b/crates/tokscale-cli/src/tui/data/mod.rs index fa3537bed..e11defe8e 100644 --- a/crates/tokscale-cli/src/tui/data/mod.rs +++ b/crates/tokscale-cli/src/tui/data/mod.rs @@ -737,8 +737,11 @@ after"#, "claude-sonnet-4".to_string(), DailyModelInfo { provider: "anthropic".to_string(), + model_id: "claude-sonnet-4".to_string(), display_name: "claude-sonnet-4".to_string(), color_key: "claude-sonnet-4".to_string(), + workspace_key: None, + workspace_label: None, tokens: tokens.clone(), cost, messages: 1, diff --git a/crates/tokscale-cli/src/tui/export.rs b/crates/tokscale-cli/src/tui/export.rs index 2ef828f35..3dcad1492 100644 --- a/crates/tokscale-cli/src/tui/export.rs +++ b/crates/tokscale-cli/src/tui/export.rs @@ -1,28 +1,45 @@ use anyhow::Result; use serde_json::json; +use tokscale_core::GroupBy; use super::data::UsageData; /// Serializes `UsageData` into the pretty-printed JSON payload used by the /// `e` export hotkey. Pure: callers are responsible for file I/O and any /// user-facing status messages. -pub fn build_export_json(data: &UsageData) -> Result { +pub fn build_export_json(data: &UsageData, group_by: &GroupBy) -> Result { let export_data = json!({ - "models": data.models.iter().map(|m| json!({ - "model": m.model, - "provider": m.provider, - "client": m.client, - "tokens": { - "input": m.tokens.input, - "output": m.tokens.output, - "cacheRead": m.tokens.cache_read, - "cacheWrite": m.tokens.cache_write, - "total": m.tokens.total() - }, - "cost": m.cost, - "performance": m.performance, - "sessionCount": m.session_count - })).collect::>(), + "groupBy": group_by.to_string(), + "models": data.models.iter().map(|m| { + let mut entry = json!({ + "model": m.model, + "provider": m.provider, + "client": m.client, + "tokens": { + "input": m.tokens.input, + "output": m.tokens.output, + "cacheRead": m.tokens.cache_read, + "cacheWrite": m.tokens.cache_write, + "total": m.tokens.total() + }, + "cost": m.cost, + "performance": m.performance, + "sessionCount": m.session_count + }); + // Workspace dimension rides in structured fields (ADR 0026), + // mirroring the `models` CLI JSON shape. + if *group_by == GroupBy::WorkspaceModel { + entry["workspaceKey"] = m + .workspace_key + .as_deref() + .map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null); + if let Some(label) = m.workspace_label.as_deref() { + entry["workspaceLabel"] = label.into(); + } + } + entry + }).collect::>(), "agents": data.agents.iter().map(|a| json!({ "agent": a.agent, "clients": a.clients, @@ -63,6 +80,7 @@ pub fn build_export_json(data: &UsageData) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::tui::data::ModelUsage; #[test] fn exported_report_keeps_degraded_source_health() { @@ -81,7 +99,7 @@ mod tests { }]; let json: serde_json::Value = - serde_json::from_str(&build_export_json(&data).unwrap()).unwrap(); + serde_json::from_str(&build_export_json(&data, &GroupBy::Model).unwrap()).unwrap(); assert_eq!(json["health"]["complete"], false); assert_eq!(json["health"]["degradedSources"], 1); @@ -91,4 +109,54 @@ mod tests { assert_eq!(json["health"]["issues"][0]["issue"], "missing-model"); assert!(json["health"].get("sources").is_none()); } + + fn model_entry(workspace_key: Option<&str>, workspace_label: Option<&str>) -> ModelUsage { + ModelUsage { + model: "claude-sonnet-4.5".to_string(), + provider: "anthropic".to_string(), + client: "claude".to_string(), + workspace_key: workspace_key.map(str::to_string), + workspace_label: workspace_label.map(str::to_string), + tokens: Default::default(), + cost: 1.0, + performance: Default::default(), + session_count: 1, + } + } + + #[test] + fn exported_report_carries_group_by_and_workspace_fields() { + let data = UsageData { + models: vec![ + model_entry(Some("/repo-a"), Some("repo-a")), + model_entry(None, Some("Unknown workspace")), + ], + ..UsageData::default() + }; + + let json: serde_json::Value = + serde_json::from_str(&build_export_json(&data, &GroupBy::WorkspaceModel).unwrap()) + .unwrap(); + + assert_eq!(json["groupBy"], "workspace,model"); + assert_eq!(json["models"][0]["workspaceKey"], "/repo-a"); + assert_eq!(json["models"][0]["workspaceLabel"], "repo-a"); + assert_eq!(json["models"][1]["workspaceKey"], serde_json::Value::Null); + assert_eq!(json["models"][1]["workspaceLabel"], "Unknown workspace"); + } + + #[test] + fn exported_report_omits_workspace_fields_outside_workspace_grouping() { + let data = UsageData { + models: vec![model_entry(Some("/repo-a"), Some("repo-a"))], + ..UsageData::default() + }; + + let json: serde_json::Value = + serde_json::from_str(&build_export_json(&data, &GroupBy::Model).unwrap()).unwrap(); + + assert_eq!(json["groupBy"], "model"); + assert!(json["models"][0].get("workspaceKey").is_none()); + assert!(json["models"][0].get("workspaceLabel").is_none()); + } } diff --git a/crates/tokscale-cli/src/tui/mod.rs b/crates/tokscale-cli/src/tui/mod.rs index 5fc03d7a2..c1f0b906c 100644 --- a/crates/tokscale-cli/src/tui/mod.rs +++ b/crates/tokscale-cli/src/tui/mod.rs @@ -108,11 +108,19 @@ fn refresh_session_data( } } +/// Sessions are group-agnostic (ADR 0026): a grouping-triggered reload +/// re-aggregates the usage projection but must not force a Sessions rescan; +/// session refresh follows source-digest changes instead. +fn session_reload_force(force: bool, group_only_reload: bool) -> bool { + force && !group_only_reload +} + fn load_background_data( loader: &DataLoader, clients: &[ClientId], group_by: &tokscale_core::GroupBy, force: bool, + session_force: bool, last_digest: Option, ) -> Result { let mut prepared = loader.prepare(clients)?; @@ -120,7 +128,7 @@ fn load_background_data( .refresh_source_inventory_signature()? .process_digest(); if !force && last_digest == Some(digest) { - let pricing_diagnostics = refresh_session_data(loader, clients, digest, force); + let pricing_diagnostics = refresh_session_data(loader, clients, digest, session_force); return Ok(BackgroundLoad::Unchanged { pricing_diagnostics, }); @@ -130,7 +138,7 @@ fn load_background_data( let session_digest = result .as_ref() .map_or(digest, |result| result.source_digest); - let _ = refresh_session_data(loader, clients, session_digest, force); + let _ = refresh_session_data(loader, clients, session_digest, session_force); result.map(|result| BackgroundLoad::Loaded { data: Box::new(result.data), digest: result.source_digest, @@ -345,7 +353,14 @@ pub fn run( thread::spawn(move || { let loader = background_data_loader(bg_home_dir, bg_since, bg_until, bg_year); let result = persist_background_load( - load_background_data(&loader, &bg_clients, &bg_group_by, bg_force, bg_last_digest), + load_background_data( + &loader, + &bg_clients, + &bg_group_by, + bg_force, + bg_force, + bg_last_digest, + ), &bg_enabled_clients, &bg_group_by, &bg_report_scope, @@ -448,6 +463,8 @@ fn run_loop_with_background( let force = should_force_source_reload(std::mem::take(&mut app.reload_force), &app.data.health); + let session_force = + session_reload_force(force, std::mem::take(&mut app.reload_group_only)); let last_digest = app.last_source_digest; let tx = bg_tx.clone(); let clients = app.scan_clients(); @@ -466,7 +483,14 @@ fn run_loop_with_background( thread::spawn(move || { let loader = background_data_loader(home_dir, since, until, year); let result = persist_background_load( - load_background_data(&loader, &clients, &group_by, force, last_digest), + load_background_data( + &loader, + &clients, + &group_by, + force, + session_force, + last_digest, + ), &enabled_clients, &group_by, &report_scope, @@ -702,6 +726,16 @@ mod tests { assert_eq!(loader.year.as_deref(), Some("2026")); } + #[test] + fn session_reload_force_skips_grouping_triggered_reloads() { + // Grouping switches re-aggregate the usage projection but leave the + // Sessions snapshot to the source-digest probe (ADR 0026). + assert!(!session_reload_force(true, true)); + assert!(session_reload_force(true, false)); + assert!(!session_reload_force(false, true)); + assert!(!session_reload_force(false, false)); + } + #[test] #[serial] fn fresh_cache_baseline_skips_a_and_reloads_changed_b() { @@ -724,6 +758,7 @@ mod tests { &clients, &tokscale_core::GroupBy::Model, false, + false, baseline ) .unwrap(), @@ -736,6 +771,7 @@ mod tests { &clients, &tokscale_core::GroupBy::Model, false, + false, baseline, ) .unwrap(); @@ -773,6 +809,7 @@ mod tests { &clients, &tokscale_core::GroupBy::Model, true, + true, last_digest, ) .unwrap(), diff --git a/crates/tokscale-cli/src/tui/ui/daily.rs b/crates/tokscale-cli/src/tui/ui/daily.rs index 96b4e2928..3e9b3c9c1 100644 --- a/crates/tokscale-cli/src/tui/ui/daily.rs +++ b/crates/tokscale-cli/src/tui/ui/daily.rs @@ -9,7 +9,7 @@ use super::model_usage_layout::{ model_usage_table_layout, ModelUsageColumn as DailyDetailColumn, ModelUsageLayoutSchema, ModelUsageTableDensity as DailyDetailTableDensity, ModelUsageTableLayout as DailyDetailTableLayout, DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, - MODEL_MIN_WIDTH, + MODEL_MIN_WIDTH, WORKSPACE_MIN_WIDTH, }; use super::table_layout::{ display_width, distributed_table_area, responsive_table_layout, width_for_column, @@ -18,10 +18,12 @@ use super::table_layout::{ use super::widgets::{ format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens, get_client_display_name, get_provider_display_name, total_tokens_cell, truncate_display_width, - truncate_model_display_name_to, viewport_scrollbar_state, MODEL_DISPLAY_MAX_WIDTH, + truncate_model_display_name_to, viewport_scrollbar_state, workspace_label_or_unknown, + MODEL_DISPLAY_MAX_WIDTH, }; use crate::tui::app::{App, SortDirection, SortField}; use crate::tui::data::DailyUsage; +use tokscale_core::GroupBy; const DATE_WIDTH: u16 = 7; const TURN_WIDTH: u16 = 6; @@ -255,13 +257,21 @@ fn daily_detail_table_layout( model_content_width: u16, provider_content_width: u16, source_content_width: u16, + workspace_content_width: u16, + group_by: &GroupBy, ) -> DailyDetailTableLayout { + let schema = if *group_by == GroupBy::WorkspaceModel { + ModelUsageLayoutSchema::WorkspaceDetail + } else { + ModelUsageLayoutSchema::Detail + }; model_usage_table_layout( table_width, model_content_width, provider_content_width, source_content_width, - ModelUsageLayoutSchema::Detail, + workspace_content_width, + schema, ) } @@ -270,6 +280,7 @@ fn daily_detail_column_header( density: DailyDetailTableDensity, ) -> &'static str { match column { + DailyDetailColumn::Workspace => "Workspace", DailyDetailColumn::Model => "Model", DailyDetailColumn::Provider => "Provider", DailyDetailColumn::Source => "Source", @@ -382,19 +393,17 @@ fn top_daily_model(day: &DailyUsage) -> Option { let mut models: BTreeMap = BTreeMap::new(); for source in day.source_breakdown.values() { - for (model_key, model) in &source.models { + for model in source.models.values() { let tokens = model.tokens.total(); - if tokens == 0 { + // Rank by the bare canonical id (ADR 0026): grouping must not + // split one model into several candidates, and the storage map + // key is never a user-visible identity. + if tokens == 0 || model.model_id.is_empty() { continue; } - let label = if model.display_name.is_empty() { - model_key.clone() - } else { - model.display_name.clone() - }; models - .entry(model_key.clone()) + .entry(model.model_id.clone()) .and_modify(|entry| { entry.tokens = entry .tokens @@ -403,8 +412,8 @@ fn top_daily_model(day: &DailyUsage) -> Option { entry.cost += model.cost; }) .or_insert_with(|| TopDailyModel { - key: model_key.clone(), - label, + key: model.model_id.clone(), + label: model.model_id.clone(), provider: model.provider.clone(), color_key: model.color_key.clone(), tokens, @@ -755,11 +764,23 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { .map(|row| display_width(&get_client_display_name(&row.source))) .max() .unwrap_or(DETAIL_SOURCE_WIDTH); + let group_by = app.group_by.borrow().clone(); + let workspace_content_width = if group_by == GroupBy::WorkspaceModel { + rows_data + .iter() + .map(|row| display_width(workspace_label_or_unknown(row.workspace.as_deref()))) + .max() + .unwrap_or(WORKSPACE_MIN_WIDTH) + } else { + 0 + }; let table_layout = daily_detail_table_layout( table_area.width, model_content_width, provider_content_width, source_content_width, + workspace_content_width, + &group_by, ); let columns = table_layout.columns.clone(); @@ -801,6 +822,11 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { let cell_for_column = |column: DailyDetailColumn| -> Cell { match column { + DailyDetailColumn::Workspace => Cell::from(truncate_display_width( + workspace_label_or_unknown(row.workspace.as_deref()), + table_layout.width_for(DailyDetailColumn::Workspace), + )) + .style(Style::default().fg(theme_muted)), DailyDetailColumn::Model => Cell::from(truncate_model_display_name_to( &row.model, table_layout.model_width, @@ -926,8 +952,11 @@ mod tests { ) -> DailyModelInfo { DailyModelInfo { provider: provider.to_string(), + model_id: color_key.to_string(), display_name: display_name.to_string(), color_key: color_key.to_string(), + workspace_key: None, + workspace_label: None, tokens: token_breakdown(tokens), cost, messages: 1, @@ -1178,9 +1207,141 @@ mod tests { assert_eq!(model.tokens, 210); } + fn workspace_daily_model( + provider: &str, + model_id: &str, + workspace: &str, + tokens: u64, + cost: f64, + ) -> DailyModelInfo { + DailyModelInfo { + provider: provider.to_string(), + model_id: model_id.to_string(), + display_name: model_id.to_string(), + color_key: model_id.to_string(), + workspace_key: Some(format!("/work/{workspace}")), + workspace_label: Some(workspace.to_string()), + tokens: token_breakdown(tokens), + cost, + messages: 1, + } + } + + #[test] + fn top_daily_model_ranking_is_grouping_invariant() { + // The same messages projected by each GroupBy: the winner must be the + // canonical merge with the bare model label in every projection + // (ADR 0026). With per-bucket keys, kimi-k2.5 (200) would beat each + // gpt-5 fragment; canonically gpt-5 wins with 210. + let mut model_projection = day("2026-06-09", 0.0); + model_projection.source_breakdown.insert( + "codex".to_string(), + daily_source( + 410, + 3.0, + vec![ + ("gpt-5", daily_model("gpt-5", "openai", "gpt-5", 210, 2.0)), + ( + "kimi-k2.5", + daily_model("kimi-k2.5", "moonshot", "kimi-k2.5", 200, 1.0), + ), + ], + ), + ); + + let mut client_model_projection = day("2026-06-09", 0.0); + client_model_projection.source_breakdown.insert( + "codex".to_string(), + daily_source( + 120, + 1.0, + vec![( + "v1|codex|gpt-5", + daily_model("gpt-5", "openai", "gpt-5", 120, 1.0), + )], + ), + ); + client_model_projection.source_breakdown.insert( + "kimi".to_string(), + daily_source( + 290, + 2.0, + vec![ + ( + "v1|kimi|gpt-5", + daily_model("gpt-5", "openai", "gpt-5", 90, 1.0), + ), + ( + "v1|kimi|kimi-k2.5", + daily_model("kimi-k2.5", "moonshot", "kimi-k2.5", 200, 1.0), + ), + ], + ), + ); + + let mut client_provider_projection = day("2026-06-09", 0.0); + client_provider_projection.source_breakdown.insert( + "codex".to_string(), + daily_source( + 410, + 3.0, + vec![ + ( + "v1|codex|openai|gpt-5", + daily_model("gpt-5", "openai", "gpt-5", 120, 1.0), + ), + ( + "v1|codex|azure|gpt-5", + daily_model("gpt-5", "azure", "gpt-5", 90, 1.0), + ), + ( + "v1|codex|moonshot|kimi-k2.5", + daily_model("kimi-k2.5", "moonshot", "kimi-k2.5", 200, 1.0), + ), + ], + ), + ); + + let mut workspace_projection = day("2026-06-09", 0.0); + workspace_projection.source_breakdown.insert( + "codex".to_string(), + daily_source( + 410, + 3.0, + vec![ + ( + "v1|codex|ws-a|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-a", 120, 1.0), + ), + ( + "v1|codex|ws-b|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-b", 90, 1.0), + ), + ( + "v1|codex|ws-a|kimi-k2.5", + workspace_daily_model("moonshot", "kimi-k2.5", "ws-a", 200, 1.0), + ), + ], + ), + ); + + for projection in [ + &model_projection, + &client_model_projection, + &client_provider_projection, + &workspace_projection, + ] { + let model = top_daily_model(projection).expect("top model should be selected"); + assert_eq!(model.key, "gpt-5"); + assert_eq!(model.label, "gpt-5", "label must be the bare model"); + assert!(!model.label.contains(" / "), "no workspace prefix"); + assert_eq!(model.tokens, 210); + } + } + #[test] fn narrow_daily_detail_layout_keeps_model_and_tokens_before_cost() { - let layout = daily_detail_table_layout(30, 80, 56, 40); + let layout = daily_detail_table_layout(30, 80, 56, 40, 0, &GroupBy::Model); assert_eq!(layout.density, DailyDetailTableDensity::VeryCompact); assert_eq!( @@ -1193,7 +1354,7 @@ mod tests { #[test] fn daily_detail_layout_stops_before_context_columns_that_do_not_fit() { - let layout = daily_detail_table_layout(74, 80, 56, 40); + let layout = daily_detail_table_layout(74, 80, 56, 40, 0, &GroupBy::Model); assert_eq!(layout.density, DailyDetailTableDensity::Core); assert_eq!( @@ -1212,7 +1373,7 @@ mod tests { #[test] fn daily_detail_layout_does_not_skip_source_to_show_messages() { - let layout = daily_detail_table_layout(56, 80, 56, 40); + let layout = daily_detail_table_layout(56, 80, 56, 40, 0, &GroupBy::Model); assert_eq!( layout.columns, @@ -1230,7 +1391,7 @@ mod tests { #[test] fn daily_detail_drops_context_columns_before_sacrificing_model() { - let layout = daily_detail_table_layout(80, 29, 40, 40); + let layout = daily_detail_table_layout(80, 29, 40, 40, 0, &GroupBy::Model); assert_eq!(layout.model_width, 29); assert!(layout.columns.contains(&DailyDetailColumn::Total)); @@ -1240,7 +1401,7 @@ mod tests { #[test] fn wide_daily_detail_layout_adds_cache_columns_before_total() { - let layout = daily_detail_table_layout(199, 80, 56, 40); + let layout = daily_detail_table_layout(199, 80, 56, 40, 0, &GroupBy::Model); assert_eq!(layout.density, DailyDetailTableDensity::Full); assert_eq!( @@ -1344,4 +1505,65 @@ mod tests { assert!(app.max_visible_items >= 1); assert!(app.max_visible_items <= (height as usize).saturating_sub(3)); } + + fn make_workspace_detail_app(width: u16) -> App { + let mut app = make_daily_app(width); + *app.group_by.borrow_mut() = GroupBy::WorkspaceModel; + let mut usage = day("2026-06-09", 30.0); + usage.source_breakdown.insert( + "codex".to_string(), + daily_source( + 300, + 3.0, + vec![ + ( + "v1|codex|ws-a|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-alpha", 200, 2.0), + ), + ( + "v1|codex|ws-b|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-beta", 100, 1.0), + ), + ], + ), + ); + app.data.daily = vec![usage]; + app.selected_daily_detail_date = + Some(NaiveDate::parse_from_str("2026-06-09", "%Y-%m-%d").unwrap()); + app + } + + #[test] + fn daily_detail_shows_workspace_column_under_workspace_grouping() { + let mut app = make_workspace_detail_app(140); + let body = render_body(&mut app, 140, 8); + + assert!( + body.contains("Workspace"), + "expected Workspace header\n{body}" + ); + assert!( + body.contains("ws-alpha"), + "expected workspace label\n{body}" + ); + assert!(body.contains("ws-beta"), "expected workspace label\n{body}"); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + assert!( + !body.contains("ws-alpha / gpt-5"), + "model cell must not carry the workspace prefix\n{body}" + ); + } + + #[test] + fn daily_detail_omits_workspace_column_outside_workspace_grouping() { + let mut app = make_workspace_detail_app(140); + *app.group_by.borrow_mut() = GroupBy::Model; + let body = render_body(&mut app, 140, 8); + + assert!( + !body.contains("Workspace"), + "Workspace column must not render under GroupBy::Model\n{body}" + ); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + } } diff --git a/crates/tokscale-cli/src/tui/ui/footer.rs b/crates/tokscale-cli/src/tui/ui/footer.rs index 2643e9317..fbbe0c7aa 100644 --- a/crates/tokscale-cli/src/tui/ui/footer.rs +++ b/crates/tokscale-cli/src/tui/ui/footer.rs @@ -228,12 +228,8 @@ fn current_count_label(app: &App) -> String { for day in &app.data.daily { for (harness, source) in &day.source_breakdown { harnesses.insert(harness.as_str()); - for (key, model) in &source.models { - models.insert(if model.color_key.is_empty() { - key.as_str() - } else { - model.color_key.as_str() - }); + for model in source.models.values() { + models.insert(model.model_id.as_str()); } } } diff --git a/crates/tokscale-cli/src/tui/ui/hourly.rs b/crates/tokscale-cli/src/tui/ui/hourly.rs index 0f22f45d7..56296428b 100644 --- a/crates/tokscale-cli/src/tui/ui/hourly.rs +++ b/crates/tokscale-cli/src/tui/ui/hourly.rs @@ -434,7 +434,7 @@ fn hourly_source_text<'a>(clients: impl Iterator) -> String { mod tests { use super::*; use crate::tui::app::{Tab, TuiConfig}; - use crate::tui::data::{HourlyUsage, TokenBreakdown}; + use crate::tui::data::{HourlyModelInfo, HourlyUsage, TokenBreakdown}; use ratatui::{backend::TestBackend, Terminal}; use std::collections::{BTreeMap, BTreeSet}; @@ -654,4 +654,89 @@ mod tests { assert!(app.max_visible_items >= 1); assert!(app.max_visible_items <= (height as usize).saturating_sub(3)); } + + fn hourly_model(provider: &str, model_id: &str, tokens: u64) -> HourlyModelInfo { + HourlyModelInfo { + provider: provider.to_string(), + model_id: model_id.to_string(), + display_name: model_id.to_string(), + color_key: model_id.to_string(), + tokens: TokenBreakdown { + input: tokens, + ..TokenBreakdown::default() + }, + cost: 1.0, + } + } + + fn grouped_hour(models: Vec<(&str, HourlyModelInfo)>) -> HourlyUsage { + let mut entry = hour(NaiveDate::from_ymd_opt(2026, 5, 29).unwrap(), 14); + entry.tokens = TokenBreakdown { + input: 100, + ..TokenBreakdown::default() + }; + entry.models = models + .into_iter() + .map(|(key, model)| (key.to_string(), model)) + .collect(); + entry + } + + #[test] + fn hourly_table_and_profile_are_grouping_invariant() { + // The Hourly table and Profile read only hour-level totals (ADR + // 0026): re-keying the per-group model buckets must not change the + // rendered output. + let projections = [ + // GroupBy::Model: one merged bucket per model. + grouped_hour(vec![("gpt-5", hourly_model("openai", "gpt-5", 100))]), + // GroupBy::ClientModel: bucketed per client. + grouped_hour(vec![ + ("v1|claude|gpt-5", hourly_model("openai", "gpt-5", 60)), + ("v1|codex|gpt-5", hourly_model("openai", "gpt-5", 40)), + ]), + // GroupBy::ClientProviderModel: bucketed per client+provider. + grouped_hour(vec![ + ( + "v1|claude|openai|gpt-5", + hourly_model("openai", "gpt-5", 60), + ), + ("v1|claude|azure|gpt-5", hourly_model("azure", "gpt-5", 40)), + ]), + // GroupBy::WorkspaceModel: bucketed per workspace. + grouped_hour(vec![ + ("v1|claude|ws-a|gpt-5", hourly_model("openai", "gpt-5", 60)), + ("v1|claude|ws-b|gpt-5", hourly_model("openai", "gpt-5", 40)), + ]), + ]; + + let mut table_outputs = Vec::new(); + let mut profile_outputs = Vec::new(); + for projection in projections { + let mut app = make_hourly_app(120); + app.data.hourly = vec![projection]; + app.data.total_tokens = 100; + app.data.total_cost = 1.0; + table_outputs.push(render_lines(&mut app, 120, 20).join("\n")); + profile_outputs.push( + hourly_profile::build_hourly_profile_lines(&app, 120) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"), + ); + } + + for output in &table_outputs { + assert_eq!(output, &table_outputs[0]); + } + for output in &profile_outputs { + assert_eq!(output, &profile_outputs[0]); + } + } } diff --git a/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs b/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs index f418ec129..de9d676df 100644 --- a/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs +++ b/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs @@ -5,7 +5,10 @@ use super::widgets::MODEL_DISPLAY_MAX_WIDTH; pub(crate) const MODEL_MIN_WIDTH: u16 = 5; pub(crate) const MODEL_MAX_WIDTH: u16 = MODEL_DISPLAY_MAX_WIDTH as u16; -pub(crate) const WORKSPACE_MODEL_MAX_WIDTH: u16 = 56; + +// Matches the Sessions tab workspace column bounds. +pub(crate) const WORKSPACE_MIN_WIDTH: u16 = 12; +pub(crate) const WORKSPACE_MAX_WIDTH: u16 = 20; pub(crate) const SOURCE_MIN_WIDTH: u16 = 8; pub(crate) const SOURCE_MAX_WIDTH: u16 = 40; @@ -33,6 +36,7 @@ pub(crate) enum ModelUsageTableDensity { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModelUsageColumn { + Workspace, Model, Source, Provider, @@ -53,6 +57,7 @@ pub(crate) enum ModelUsageLayoutSchema { Models, WorkspaceModels, Detail, + WorkspaceDetail, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -71,19 +76,20 @@ impl ModelUsageTableLayout { fn column_order(column: ModelUsageColumn) -> u16 { match column { - ModelUsageColumn::Model => 0, - ModelUsageColumn::Source => 10, - ModelUsageColumn::Provider => 20, - ModelUsageColumn::Messages => 30, - ModelUsageColumn::Input => 40, - ModelUsageColumn::Output => 50, - ModelUsageColumn::CacheRate => 60, - ModelUsageColumn::CacheRead => 70, - ModelUsageColumn::CacheWrite => 80, - ModelUsageColumn::Total => 90, - ModelUsageColumn::Cost => 100, - ModelUsageColumn::CostPerMillion => 110, - ModelUsageColumn::Performance => 120, + ModelUsageColumn::Workspace => 0, + ModelUsageColumn::Model => 10, + ModelUsageColumn::Source => 20, + ModelUsageColumn::Provider => 30, + ModelUsageColumn::Messages => 40, + ModelUsageColumn::Input => 50, + ModelUsageColumn::Output => 60, + ModelUsageColumn::CacheRate => 70, + ModelUsageColumn::CacheRead => 80, + ModelUsageColumn::CacheWrite => 90, + ModelUsageColumn::Total => 100, + ModelUsageColumn::Cost => 110, + ModelUsageColumn::CostPerMillion => 120, + ModelUsageColumn::Performance => 130, } } @@ -92,31 +98,46 @@ fn model_usage_columns( model_content_width: u16, provider_content_width: u16, source_content_width: u16, + workspace_content_width: u16, ) -> Vec> { - let model_max_width = match schema { - ModelUsageLayoutSchema::WorkspaceModels => WORKSPACE_MODEL_MAX_WIDTH, - ModelUsageLayoutSchema::Models | ModelUsageLayoutSchema::Detail => MODEL_MAX_WIDTH, - }; + let has_workspace = matches!( + schema, + ModelUsageLayoutSchema::WorkspaceModels | ModelUsageLayoutSchema::WorkspaceDetail + ); let source_max_width = match schema { - ModelUsageLayoutSchema::Detail => DETAIL_SOURCE_MAX_WIDTH, + ModelUsageLayoutSchema::Detail | ModelUsageLayoutSchema::WorkspaceDetail => { + DETAIL_SOURCE_MAX_WIDTH + } ModelUsageLayoutSchema::Models | ModelUsageLayoutSchema::WorkspaceModels => { SOURCE_MAX_WIDTH } }; let provider_max_width = match schema { - ModelUsageLayoutSchema::Detail => DETAIL_PROVIDER_MAX_WIDTH, + ModelUsageLayoutSchema::Detail | ModelUsageLayoutSchema::WorkspaceDetail => { + DETAIL_PROVIDER_MAX_WIDTH + } ModelUsageLayoutSchema::Models | ModelUsageLayoutSchema::WorkspaceModels => { PROVIDER_MAX_WIDTH } }; - let mut columns = vec![ + let mut columns = Vec::new(); + if has_workspace { + columns.push(ResponsiveColumn::measured_required( + ModelUsageColumn::Workspace, + column_order(ModelUsageColumn::Workspace), + WORKSPACE_MIN_WIDTH, + workspace_content_width, + WORKSPACE_MAX_WIDTH, + )); + } + columns.extend([ ResponsiveColumn::measured_required( ModelUsageColumn::Model, column_order(ModelUsageColumn::Model), MODEL_MIN_WIDTH, model_content_width, - model_max_width, + MODEL_MAX_WIDTH, ), ResponsiveColumn::fixed_required( ModelUsageColumn::Total, @@ -145,10 +166,10 @@ fn model_usage_columns( provider_content_width, provider_max_width, ), - ]; + ]); match schema { - ModelUsageLayoutSchema::Detail => { + ModelUsageLayoutSchema::Detail | ModelUsageLayoutSchema::WorkspaceDetail => { columns.push(ResponsiveColumn::fixed_optional( ModelUsageColumn::Messages, 40, @@ -272,6 +293,7 @@ pub(crate) fn model_usage_table_layout( model_content_width: u16, provider_content_width: u16, source_content_width: u16, + workspace_content_width: u16, schema: ModelUsageLayoutSchema, ) -> ModelUsageTableLayout { let specs = model_usage_columns( @@ -279,6 +301,7 @@ pub(crate) fn model_usage_table_layout( model_content_width, provider_content_width, source_content_width, + workspace_content_width, ); let layout = responsive_table_layout(table_width, &specs); let model_width = layout.width_for(ModelUsageColumn::Model); @@ -308,6 +331,7 @@ mod tests { model_content_width, provider_content_width, source_content_width, + 0, schema, ) } @@ -545,9 +569,16 @@ mod tests { } #[test] - fn workspace_schema_uses_workspace_model_cap() { - let layout = layout(200, 80, 8, 8, ModelUsageLayoutSchema::WorkspaceModels); + fn workspace_schema_splits_workspace_and_model_columns() { + let layout = + model_usage_table_layout(200, 80, 8, 8, 80, ModelUsageLayoutSchema::WorkspaceModels); - assert_eq!(layout.model_width, WORKSPACE_MODEL_MAX_WIDTH as usize); + assert_eq!(layout.columns[0], ModelUsageColumn::Workspace); + assert_eq!(layout.columns[1], ModelUsageColumn::Model); + assert_eq!( + layout.width_for(ModelUsageColumn::Workspace), + WORKSPACE_MAX_WIDTH as usize + ); + assert_eq!(layout.model_width, MODEL_MAX_WIDTH as usize); } } diff --git a/crates/tokscale-cli/src/tui/ui/models.rs b/crates/tokscale-cli/src/tui/ui/models.rs index 8721e426f..ca8bfa649 100644 --- a/crates/tokscale-cli/src/tui/ui/models.rs +++ b/crates/tokscale-cli/src/tui/ui/models.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{ use super::model_usage_layout::{ model_usage_table_layout, ModelUsageColumn as ModelsColumn, ModelUsageLayoutSchema, ModelUsageTableDensity as ModelsTableDensity, ModelUsageTableLayout as ModelsTableLayout, - DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, MODEL_MIN_WIDTH, + DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, MODEL_MIN_WIDTH, WORKSPACE_MIN_WIDTH, }; use super::table_layout::{ display_width, distributed_table_area, DISTRIBUTED_TABLE_FLEX, TABLE_COLUMN_SPACING, @@ -14,39 +14,44 @@ use super::table_layout::{ use super::widgets::{ format_cache_hit_rate, format_cost, format_cost_per_million, format_ms_per_1k, format_tokens, get_client_display_name, get_provider_display_name, total_tokens_cell, truncate_display_width, - truncate_model_display_name_to, viewport_scrollbar_state, + truncate_model_display_name_to, viewport_scrollbar_state, workspace_label_or_unknown, }; use crate::tui::app::{App, SortDirection, SortField}; use tokscale_core::GroupBy; fn workspace_label(model: &crate::tui::data::ModelUsage) -> &str { - model - .workspace_label - .as_deref() - .unwrap_or("Unknown workspace") + workspace_label_or_unknown(model.workspace_label.as_deref()) } -fn model_display_name(model: &crate::tui::data::ModelUsage, group_by: &GroupBy) -> String { - if *group_by == GroupBy::WorkspaceModel { - format!("{} / {}", workspace_label(model), model.model) - } else { - model.model.clone() - } +/// The Model column always shows the bare canonical model; under +/// `GroupBy::WorkspaceModel` the workspace dimension lives in its own column +/// instead of a "workspace / model" prefix (ADR 0026). +fn model_display_name(model: &crate::tui::data::ModelUsage) -> &str { + &model.model } -fn model_content_width(models: &[&crate::tui::data::ModelUsage], group_by: &GroupBy) -> u16 { +fn model_content_width(models: &[&crate::tui::data::ModelUsage]) -> u16 { models .iter() - .map(|model| display_width(&model_display_name(model, group_by))) + .map(|model| display_width(model_display_name(model))) .max() .unwrap_or(MODEL_MIN_WIDTH) } +fn workspace_content_width(models: &[&crate::tui::data::ModelUsage]) -> u16 { + models + .iter() + .map(|model| display_width(workspace_label(model))) + .max() + .unwrap_or(WORKSPACE_MIN_WIDTH) +} + fn models_table_layout( table_width: u16, model_content_width: u16, provider_content_width: u16, source_content_width: u16, + workspace_content_width: u16, group_by: &GroupBy, ) -> ModelsTableLayout { let schema = if *group_by == GroupBy::WorkspaceModel { @@ -60,6 +65,7 @@ fn models_table_layout( model_content_width, provider_content_width, source_content_width, + workspace_content_width, schema, ) } @@ -70,6 +76,7 @@ fn model_column_header( density: ModelsTableDensity, ) -> &'static str { match column { + ModelsColumn::Workspace => "Workspace", ModelsColumn::Model => "Model", ModelsColumn::Messages => "Msgs", ModelsColumn::Provider => "Provider", @@ -161,7 +168,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { return; } - let model_content_width = model_content_width(&models, &group_by); + let model_content_width = model_content_width(&models); let provider_content_width = models .iter() .map(|model| display_width(&get_provider_display_name(&model.provider))) @@ -172,12 +179,18 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .map(|model| display_width(&get_client_display_name(&model.client))) .max() .unwrap_or(DETAIL_SOURCE_WIDTH); + let workspace_content_width = if group_by == GroupBy::WorkspaceModel { + workspace_content_width(&models) + } else { + 0 + }; let visible_models = &models[start..end]; let table_layout = models_table_layout( table_area.width, model_content_width, provider_content_width, source_content_width, + workspace_content_width, &group_by, ); let columns = table_layout.columns.clone(); @@ -209,11 +222,16 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let is_striped = idx % 2 == 1; let model_color = app.model_color_for(&model.provider, &model.model); - let display_name = model_display_name(model, &group_by); + let display_name = model_display_name(model); let cell_for_column = |column: ModelsColumn| -> Cell { match column { + ModelsColumn::Workspace => Cell::from(truncate_display_width( + workspace_label(model), + table_layout.width_for(ModelsColumn::Workspace), + )) + .style(Style::default().fg(theme_muted)), ModelsColumn::Model => Cell::from(truncate_model_display_name_to( - &display_name, + display_name, table_layout.model_width, )) .style( @@ -312,7 +330,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { #[cfg(test)] mod tests { use super::super::model_usage_layout::{ - MODEL_MAX_WIDTH, PROVIDER_MAX_WIDTH, SOURCE_MAX_WIDTH, WORKSPACE_MODEL_MAX_WIDTH, + MODEL_MAX_WIDTH, PROVIDER_MAX_WIDTH, SOURCE_MAX_WIDTH, WORKSPACE_MAX_WIDTH, }; use super::*; @@ -324,7 +342,7 @@ mod tests { } fn model_layout(table_width: u16, model: u16, provider: u16, source: u16) -> ModelsTableLayout { - models_table_layout(table_width, model, provider, source, &GroupBy::Model) + models_table_layout(table_width, model, provider, source, 0, &GroupBy::Model) } fn workspace_model_layout( @@ -338,6 +356,7 @@ mod tests { model, provider, source, + 22, &GroupBy::WorkspaceModel, ) } @@ -364,7 +383,7 @@ mod tests { #[test] fn narrow_model_layout_stops_before_context_columns_before_truncating_model() { - let layout = models_table_layout(74, 80, 56, 40, &GroupBy::Model); + let layout = models_table_layout(74, 80, 56, 40, 0, &GroupBy::Model); assert_eq!( layout.columns, @@ -378,7 +397,7 @@ mod tests { #[test] fn very_narrow_model_layout_keeps_tokens_before_optional_detail_columns() { - let layout = models_table_layout(54, 80, 56, 40, &GroupBy::Model); + let layout = models_table_layout(54, 80, 56, 40, 0, &GroupBy::Model); assert_eq!(layout.density, ModelsTableDensity::Core); assert_eq!( @@ -421,14 +440,14 @@ mod tests { let base = workspace_model_layout(160, 28, 42, 34); let wide = workspace_model_layout(200, 28, 42, 34); - assert_eq!(length_at(&wide.widths, 0) as usize, wide.model_width); - assert!(wide.model_width <= WORKSPACE_MODEL_MAX_WIDTH as usize); + assert_eq!(length_at(&wide.widths, 1) as usize, wide.model_width); + assert!(wide.model_width <= MODEL_MAX_WIDTH as usize); assert!(wide.columns.contains(&ModelsColumn::Source)); assert!(wide.columns.contains(&ModelsColumn::Provider)); - assert_eq!(length_at(&base.widths, 1), 34); - assert_eq!(length_at(&base.widths, 2), PROVIDER_MAX_WIDTH); - assert_eq!(length_at(&wide.widths, 1), 34); - assert_eq!(length_at(&wide.widths, 2), PROVIDER_MAX_WIDTH); + assert_eq!(length_at(&base.widths, 2), 34); + assert_eq!(length_at(&base.widths, 3), PROVIDER_MAX_WIDTH); + assert_eq!(length_at(&wide.widths, 2), 34); + assert_eq!(length_at(&wide.widths, 3), PROVIDER_MAX_WIDTH); } #[test] @@ -462,11 +481,12 @@ mod tests { } #[test] - fn workspace_model_column_uses_workspace_cap_on_wide_tables() { + fn workspace_and_model_columns_stay_capped_on_very_wide_tables() { let layout = workspace_model_layout(400, 80, 120, 120); - assert_eq!(length_at(&layout.widths, 0), WORKSPACE_MODEL_MAX_WIDTH); - assert_eq!(layout.model_width, WORKSPACE_MODEL_MAX_WIDTH as usize); + assert_eq!(length_at(&layout.widths, 0), WORKSPACE_MAX_WIDTH); + assert_eq!(length_at(&layout.widths, 1), MODEL_MAX_WIDTH); + assert_eq!(layout.model_width, MODEL_MAX_WIDTH as usize); } #[test] @@ -480,7 +500,7 @@ mod tests { } #[test] - fn workspace_model_content_width_includes_workspace_prefix() { + fn workspace_model_widths_split_workspace_from_bare_model() { let model = crate::tui::data::ModelUsage { model: "gpt-5".to_string(), provider: "openai".to_string(), @@ -500,11 +520,12 @@ mod tests { }; let models = vec![&model]; + assert_eq!(model_content_width(&models), 5); assert_eq!( - model_content_width(&models, &GroupBy::WorkspaceModel), - display_width("project-with-long-name / gpt-5") + workspace_content_width(&models), + display_width("project-with-long-name") ); - assert_eq!(model_content_width(&models, &GroupBy::Model), 5); + assert_eq!(model_display_name(&model), "gpt-5"); } #[test] @@ -522,4 +543,105 @@ mod tests { .collect::>() ); } + + use crate::tui::app::Tab; + use crate::tui::app::TuiConfig; + use ratatui::{backend::TestBackend, Terminal}; + + fn make_models_app(width: u16, group_by: GroupBy) -> App { + let config = TuiConfig { + theme: Some("blue".to_string()), + refresh: 0, + no_refresh: false, + home_dir: None, + clients: None, + since: None, + until: None, + year: None, + initial_tab: None, + }; + let mut app = App::new_with_cached_data(config, None).unwrap(); + app.terminal_width = width; + app.current_tab = Tab::Models; + *app.group_by.borrow_mut() = group_by; + app + } + + fn workspace_model_usage( + model: &str, + workspace: &str, + cost: f64, + ) -> crate::tui::data::ModelUsage { + crate::tui::data::ModelUsage { + model: model.to_string(), + provider: "openai".to_string(), + client: "opencode".to_string(), + workspace_key: Some(format!("/work/{workspace}")), + workspace_label: Some(workspace.to_string()), + tokens: crate::tui::data::TokenBreakdown::default(), + cost, + performance: Default::default(), + session_count: 1, + } + } + + fn render_body(app: &mut App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render(frame, app, Rect::new(0, 0, width, height))) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn models_table_shows_workspace_column_under_workspace_grouping() { + let mut app = make_models_app(140, GroupBy::WorkspaceModel); + app.data.models = vec![ + workspace_model_usage("gpt-5", "ws-alpha", 3.0), + workspace_model_usage("gpt-5", "ws-beta", 1.0), + ]; + + let body = render_body(&mut app, 140, 8); + + assert!( + body.contains("Workspace"), + "expected Workspace header\n{body}" + ); + assert!( + body.contains("ws-alpha"), + "expected workspace label\n{body}" + ); + assert!(body.contains("ws-beta"), "expected workspace label\n{body}"); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + assert!( + !body.contains("ws-alpha / gpt-5"), + "model cell must not carry the workspace prefix\n{body}" + ); + } + + #[test] + fn models_table_omits_workspace_column_outside_workspace_grouping() { + let mut app = make_models_app(140, GroupBy::Model); + app.data.models = vec![workspace_model_usage("gpt-5", "ws-alpha", 3.0)]; + + let body = render_body(&mut app, 140, 8); + + assert!( + !body.contains("Workspace"), + "Workspace column must not render under GroupBy::Model\n{body}" + ); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + } } diff --git a/crates/tokscale-cli/src/tui/ui/overview.rs b/crates/tokscale-cli/src/tui/ui/overview.rs index c57d3cdb9..0e460fa3e 100644 --- a/crates/tokscale-cli/src/tui/ui/overview.rs +++ b/crates/tokscale-cli/src/tui/ui/overview.rs @@ -59,10 +59,8 @@ fn collect_overview_data(app: &App) -> OverviewData { for day in &app.data.daily { for source in day.source_breakdown.values() { - for (model_key, model) in &source.models { - let canonical = - canonical_model_key(model_key, &model.display_name, &model.color_key); - let entry = overview.models.entry(canonical).or_default(); + for model in source.models.values() { + let entry = overview.models.entry(model.model_id.clone()).or_default(); if entry.provider.is_empty() && !model.provider.is_empty() { entry.provider = model.provider.clone(); } @@ -78,16 +76,6 @@ fn collect_overview_data(app: &App) -> OverviewData { overview } -fn canonical_model_key(model_key: &str, display_name: &str, color_key: &str) -> String { - if !color_key.is_empty() { - color_key.to_string() - } else if !display_name.is_empty() { - display_name.to_string() - } else { - model_key.to_string() - } -} - fn render_chart(frame: &mut Frame, app: &App, area: Rect) { if area.is_empty() { return; @@ -105,10 +93,8 @@ fn render_chart(frame: &mut Frame, app: &App, area: Rect) { .map(|day| { let mut models = BTreeMap::::new(); for source in day.source_breakdown.values() { - for (model_key, model) in &source.models { - let canonical = - canonical_model_key(model_key, &model.display_name, &model.color_key); - let entry = models.entry(canonical).or_default(); + for model in source.models.values() { + let entry = models.entry(model.model_id.clone()).or_default(); if entry.provider.is_empty() && !model.provider.is_empty() { entry.provider = model.provider.clone(); } @@ -143,10 +129,8 @@ fn render_chart(frame: &mut Frame, app: &App, area: Rect) { .rev() .map(|hour| { let mut models = BTreeMap::::new(); - for (model_key, model) in &hour.models { - let canonical = - canonical_model_key(model_key, &model.display_name, &model.color_key); - let entry = models.entry(canonical).or_default(); + for model in hour.models.values() { + let entry = models.entry(model.model_id.clone()).or_default(); if entry.provider.is_empty() && !model.provider.is_empty() { entry.provider = model.provider.clone(); } @@ -269,18 +253,6 @@ fn truncate_string(value: &str, max_chars: usize) -> String { mod tests { use super::*; - #[test] - fn canonical_model_prefers_color_key_over_grouped_label() { - assert_eq!( - canonical_model_key( - "workspace-a / claude-sonnet-4", - "workspace-a / claude-sonnet-4", - "claude-sonnet-4", - ), - "claude-sonnet-4" - ); - } - #[test] fn legend_only_includes_complete_items_that_fit() { let models = ["123456789012345678"; 5]; diff --git a/crates/tokscale-cli/src/tui/ui/overview_snapshot.rs b/crates/tokscale-cli/src/tui/ui/overview_snapshot.rs index 7f7f18b7b..ce83e6e96 100644 --- a/crates/tokscale-cli/src/tui/ui/overview_snapshot.rs +++ b/crates/tokscale-cli/src/tui/ui/overview_snapshot.rs @@ -98,15 +98,8 @@ fn collect_snapshot(app: &App) -> SnapshotData { harness_entry.cost += source.cost.max(0.0); } - for (model_key, model) in &source.models { - let key = if !model.color_key.is_empty() { - model.color_key.clone() - } else if !model.display_name.is_empty() { - model.display_name.clone() - } else { - model_key.clone() - }; - let model_entry = data.models.entry(key).or_default(); + for model in source.models.values() { + let model_entry = data.models.entry(model.model_id.clone()).or_default(); model_entry.tokens = model_entry.tokens.saturating_add(model.tokens.total()); if model.cost.is_finite() { model_entry.cost += model.cost.max(0.0); diff --git a/crates/tokscale-cli/src/tui/ui/period.rs b/crates/tokscale-cli/src/tui/ui/period.rs index 80b7a6463..e8b256aa4 100644 --- a/crates/tokscale-cli/src/tui/ui/period.rs +++ b/crates/tokscale-cli/src/tui/ui/period.rs @@ -9,7 +9,7 @@ use super::model_usage_layout::{ model_usage_table_layout, ModelUsageColumn as PeriodDetailColumn, ModelUsageLayoutSchema, ModelUsageTableDensity as PeriodDetailTableDensity, ModelUsageTableLayout as PeriodDetailTableLayout, DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, - MODEL_MIN_WIDTH, + MODEL_MIN_WIDTH, WORKSPACE_MIN_WIDTH, }; use super::table_layout::{ display_width, distributed_table_area, responsive_table_layout, width_for_column, @@ -18,10 +18,12 @@ use super::table_layout::{ use super::widgets::{ format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens, get_client_display_name, get_provider_display_name, total_tokens_cell, truncate_display_width, - truncate_model_display_name_to, viewport_scrollbar_state, MODEL_DISPLAY_MAX_WIDTH, + truncate_model_display_name_to, viewport_scrollbar_state, workspace_label_or_unknown, + MODEL_DISPLAY_MAX_WIDTH, }; use crate::tui::app::{App, SortDirection, SortField}; use crate::tui::data::{PeriodKind, PeriodUsage}; +use tokscale_core::GroupBy; const PERIOD_MIN_WIDTH: u16 = 6; const PERIOD_MAX_WIDTH: u16 = 20; @@ -307,13 +309,21 @@ fn period_detail_table_layout( model_content_width: u16, provider_content_width: u16, source_content_width: u16, + workspace_content_width: u16, + group_by: &GroupBy, ) -> PeriodDetailTableLayout { + let schema = if *group_by == GroupBy::WorkspaceModel { + ModelUsageLayoutSchema::WorkspaceDetail + } else { + ModelUsageLayoutSchema::Detail + }; model_usage_table_layout( table_width, model_content_width, provider_content_width, source_content_width, - ModelUsageLayoutSchema::Detail, + workspace_content_width, + schema, ) } @@ -342,6 +352,7 @@ fn period_detail_column_header( density: PeriodDetailTableDensity, ) -> &'static str { match column { + PeriodDetailColumn::Workspace => "Workspace", PeriodDetailColumn::Model => "Model", PeriodDetailColumn::Provider => "Provider", PeriodDetailColumn::Source => "Source", @@ -408,19 +419,17 @@ fn top_period_model(period: &PeriodUsage) -> Option { let mut models: BTreeMap = BTreeMap::new(); for source in period.source_breakdown.values() { - for (model_key, model) in &source.models { + for model in source.models.values() { let tokens = model.tokens.total(); - if tokens == 0 { + // Rank by the bare canonical id (ADR 0026): grouping must not + // split one model into several candidates, and the storage map + // key is never a user-visible identity. + if tokens == 0 || model.model_id.is_empty() { continue; } - let label = if model.display_name.is_empty() { - model_key.clone() - } else { - model.display_name.clone() - }; models - .entry(model_key.clone()) + .entry(model.model_id.clone()) .and_modify(|entry| { entry.tokens = entry .tokens @@ -429,8 +438,8 @@ fn top_period_model(period: &PeriodUsage) -> Option { entry.cost += model.cost; }) .or_insert_with(|| TopPeriodModel { - key: model_key.clone(), - label, + key: model.model_id.clone(), + label: model.model_id.clone(), provider: model.provider.clone(), color_key: model.color_key.clone(), tokens, @@ -540,11 +549,23 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { .map(|row| display_width(&get_client_display_name(&row.source))) .max() .unwrap_or(DETAIL_SOURCE_WIDTH); + let group_by = app.group_by.borrow().clone(); + let workspace_content_width = if group_by == GroupBy::WorkspaceModel { + rows_data + .iter() + .map(|row| display_width(workspace_label_or_unknown(row.workspace.as_deref()))) + .max() + .unwrap_or(WORKSPACE_MIN_WIDTH) + } else { + 0 + }; let table_layout = period_detail_table_layout( table_area.width, model_content_width, provider_content_width, source_content_width, + workspace_content_width, + &group_by, ); let columns = table_layout.columns.clone(); @@ -582,6 +603,11 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { let cell_for_column = |column: PeriodDetailColumn| -> Cell { match column { + PeriodDetailColumn::Workspace => Cell::from(truncate_display_width( + workspace_label_or_unknown(row.workspace.as_deref()), + table_layout.width_for(PeriodDetailColumn::Workspace), + )) + .style(Style::default().fg(theme_muted)), PeriodDetailColumn::Model => Cell::from(truncate_model_display_name_to( &row.model, table_layout.model_width, @@ -1019,4 +1045,252 @@ mod tests { fn period_start_clamps_stale_scroll_to_last_period() { assert_eq!(clamped_period_start(100, 8), 7); } + + use crate::tui::app::{PeriodDetailSelection, Tab, TuiConfig}; + use crate::tui::data::{DailyModelInfo, DailySourceInfo, DailyUsage, TokenBreakdown}; + use chrono::NaiveDate; + use ratatui::{backend::TestBackend, Terminal}; + + fn token_breakdown(input: u64) -> TokenBreakdown { + TokenBreakdown { + input, + ..TokenBreakdown::default() + } + } + + fn daily_model(provider: &str, model_id: &str, tokens: u64, cost: f64) -> DailyModelInfo { + DailyModelInfo { + provider: provider.to_string(), + model_id: model_id.to_string(), + display_name: model_id.to_string(), + color_key: model_id.to_string(), + workspace_key: None, + workspace_label: None, + tokens: token_breakdown(tokens), + cost, + messages: 1, + } + } + + fn workspace_daily_model( + provider: &str, + model_id: &str, + workspace: &str, + tokens: u64, + cost: f64, + ) -> DailyModelInfo { + DailyModelInfo { + workspace_key: Some(format!("/work/{workspace}")), + workspace_label: Some(workspace.to_string()), + ..daily_model(provider, model_id, tokens, cost) + } + } + + fn period_with_models(models: Vec<(&str, DailyModelInfo)>) -> PeriodUsage { + let tokens = models + .iter() + .map(|(_, model)| model.tokens.total()) + .fold(0_u64, u64::saturating_add); + PeriodUsage { + section_year: 2026, + section_label: "2026".to_string(), + label: "2026-06".to_string(), + short_label: "06".to_string(), + start_date: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2026, 6, 30).unwrap(), + tokens: token_breakdown(tokens), + cost: 0.0, + source_breakdown: BTreeMap::from([( + "codex".to_string(), + DailySourceInfo { + tokens: token_breakdown(tokens), + cost: 0.0, + models: models + .into_iter() + .map(|(key, model)| (key.to_string(), model)) + .collect(), + }, + )]), + message_count: 0, + turn_count: 0, + active_days: 1, + } + } + + #[test] + fn top_period_model_ranking_is_grouping_invariant() { + // Same messages projected by each GroupBy: the winner must be the + // canonical merge with the bare model label in every projection + // (ADR 0026). Per-bucket, kimi-k2.5 (200) beats each gpt-5 fragment; + // canonically gpt-5 wins with 210. + let model_projection = period_with_models(vec![ + ("gpt-5", daily_model("openai", "gpt-5", 210, 2.0)), + ("kimi-k2.5", daily_model("moonshot", "kimi-k2.5", 200, 1.0)), + ]); + let client_model_projection = period_with_models(vec![ + ("v1|codex|gpt-5", daily_model("openai", "gpt-5", 120, 1.0)), + ("v1|kimi|gpt-5", daily_model("openai", "gpt-5", 90, 1.0)), + ( + "v1|kimi|kimi-k2.5", + daily_model("moonshot", "kimi-k2.5", 200, 1.0), + ), + ]); + let client_provider_projection = period_with_models(vec![ + ( + "v1|codex|openai|gpt-5", + daily_model("openai", "gpt-5", 120, 1.0), + ), + ( + "v1|codex|azure|gpt-5", + daily_model("azure", "gpt-5", 90, 1.0), + ), + ( + "v1|codex|moonshot|kimi-k2.5", + daily_model("moonshot", "kimi-k2.5", 200, 1.0), + ), + ]); + let workspace_projection = period_with_models(vec![ + ( + "v1|codex|ws-a|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-a", 120, 1.0), + ), + ( + "v1|codex|ws-b|gpt-5", + workspace_daily_model("openai", "gpt-5", "ws-b", 90, 1.0), + ), + ( + "v1|codex|ws-a|kimi-k2.5", + workspace_daily_model("moonshot", "kimi-k2.5", "ws-a", 200, 1.0), + ), + ]); + + for projection in [ + &model_projection, + &client_model_projection, + &client_provider_projection, + &workspace_projection, + ] { + let model = top_period_model(projection).expect("top model should be selected"); + assert_eq!(model.key, "gpt-5"); + assert_eq!(model.label, "gpt-5", "label must be the bare model"); + assert!(!model.label.contains(" / "), "no workspace prefix"); + assert_eq!(model.tokens, 210); + } + } + + fn make_period_app(width: u16) -> App { + let config = TuiConfig { + theme: Some("blue".to_string()), + refresh: 0, + no_refresh: false, + home_dir: None, + clients: None, + since: None, + until: None, + year: None, + initial_tab: None, + }; + let mut app = App::new_with_cached_data(config, None).unwrap(); + app.terminal_width = width; + app.current_tab = Tab::Monthly; + app.sort_field = SortField::Date; + app.sort_direction = SortDirection::Descending; + app + } + + fn workspace_detail_day() -> DailyUsage { + DailyUsage { + date: NaiveDate::from_ymd_opt(2026, 6, 9).unwrap(), + tokens: token_breakdown(300), + cost: 3.0, + source_breakdown: BTreeMap::from([( + "codex".to_string(), + DailySourceInfo { + tokens: token_breakdown(300), + cost: 3.0, + models: BTreeMap::from([ + ( + "v1|codex|ws-a|gpt-5".to_string(), + workspace_daily_model("openai", "gpt-5", "ws-alpha", 200, 2.0), + ), + ( + "v1|codex|ws-b|gpt-5".to_string(), + workspace_daily_model("openai", "gpt-5", "ws-beta", 100, 1.0), + ), + ]), + }, + )]), + message_count: 10, + turn_count: 3, + } + } + + fn select_monthly_period(app: &mut App) { + let periods = crate::tui::data::build_period_usage(&app.data.daily, PeriodKind::Monthly); + let period = periods.first().expect("one monthly period"); + app.selected_period_detail = Some(PeriodDetailSelection { + kind: PeriodKind::Monthly, + start_date: period.start_date, + end_date: period.end_date, + }); + } + + fn render_monthly_body(app: &mut App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_monthly(frame, app, Rect::new(0, 0, width, height))) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn period_detail_shows_workspace_column_under_workspace_grouping() { + let mut app = make_period_app(140); + *app.group_by.borrow_mut() = GroupBy::WorkspaceModel; + app.data.daily = vec![workspace_detail_day()]; + select_monthly_period(&mut app); + + let body = render_monthly_body(&mut app, 140, 8); + + assert!( + body.contains("Workspace"), + "expected Workspace header\n{body}" + ); + assert!( + body.contains("ws-alpha"), + "expected workspace label\n{body}" + ); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + assert!( + !body.contains("ws-alpha / gpt-5"), + "model cell must not carry the workspace prefix\n{body}" + ); + } + + #[test] + fn period_detail_omits_workspace_column_outside_workspace_grouping() { + let mut app = make_period_app(140); + app.data.daily = vec![workspace_detail_day()]; + select_monthly_period(&mut app); + + let body = render_monthly_body(&mut app, 140, 8); + + assert!( + !body.contains("Workspace"), + "Workspace column must not render under GroupBy::Model\n{body}" + ); + assert!(body.contains("gpt-5"), "expected bare model name\n{body}"); + } } diff --git a/crates/tokscale-cli/src/tui/ui/stats.rs b/crates/tokscale-cli/src/tui/ui/stats.rs index d869bbe9a..076329250 100644 --- a/crates/tokscale-cli/src/tui/ui/stats.rs +++ b/crates/tokscale-cli/src/tui/ui/stats.rs @@ -306,10 +306,10 @@ fn rank_canonical_models(daily: &DailyUsage) -> Vec { for source in daily.source_breakdown.values() { for model in source.models.values() { let tokens = model.tokens.total(); - if model.color_key.is_empty() || tokens == 0 { + if model.model_id.is_empty() || tokens == 0 { continue; } - let entry = totals.entry(model.color_key.clone()).or_default(); + let entry = totals.entry(model.model_id.clone()).or_default(); entry.0 = entry.0.saturating_add(tokens); entry.1 += model.cost; } @@ -726,8 +726,11 @@ mod tests { ) -> DailyModelInfo { DailyModelInfo { provider: provider.to_string(), + model_id: color_key.to_string(), display_name: display_name.to_string(), color_key: color_key.to_string(), + workspace_key: None, + workspace_label: None, tokens: token_breakdown(tokens), cost, messages: 0, @@ -1070,7 +1073,7 @@ mod tests { } #[test] - fn canonical_ranking_uses_color_key_instead_of_display_name() { + fn canonical_ranking_uses_model_id_instead_of_display_name() { let date = NaiveDate::from_ymd_opt(2026, 7, 16).unwrap(); let daily = day_usage( date, @@ -1149,6 +1152,61 @@ mod tests { ); } + #[test] + fn model_and_client_provider_projection_rankings_are_identical() { + let date = NaiveDate::from_ymd_opt(2026, 7, 16).unwrap(); + let model_projection = day_usage( + date, + 100, + 2.0, + vec![( + "harness", + source_info( + 100, + 2.0, + vec![( + "gpt-5.4", + model_info("openai", "gpt-5.4", "gpt-5.4", 100, 2.0), + )], + ), + )], + ); + let client_provider_projection = day_usage( + date, + 100, + 2.0, + vec![ + ( + "codex", + source_info( + 60, + 1.2, + vec![( + "v1|codex|openai|gpt-5.4", + model_info("openai", "gpt-5.4", "gpt-5.4", 60, 1.2), + )], + ), + ), + ( + "kimi", + source_info( + 40, + 0.8, + vec![( + "v1|kimi|azure|gpt-5.4", + model_info("azure", "gpt-5.4", "gpt-5.4", 40, 0.8), + )], + ), + ), + ], + ); + + assert_eq!( + rank_canonical_models(&model_projection), + rank_canonical_models(&client_provider_projection) + ); + } + #[test] fn graph_usage_without_daily_detail_uses_the_english_message() { let mut app = make_app(120); diff --git a/crates/tokscale-cli/src/tui/ui/widgets.rs b/crates/tokscale-cli/src/tui/ui/widgets.rs index 643895856..47774005f 100644 --- a/crates/tokscale-cli/src/tui/ui/widgets.rs +++ b/crates/tokscale-cli/src/tui/ui/widgets.rs @@ -169,6 +169,12 @@ pub(crate) fn get_client_display_name(client: &str) -> String { display_comma_list(client, get_single_client_display_name) } +/// Fallback label for the Workspace column when a row carries no workspace +/// dimension. Shared by the Models table and the Daily/Period detail tables. +pub(crate) fn workspace_label_or_unknown(workspace: Option<&str>) -> &str { + workspace.unwrap_or("Unknown workspace") +} + fn get_single_client_display_name(client: &str) -> String { let config = TokscaleConfig::initialized(); if let Some(name) = config.get_client_display_name(client) { diff --git a/crates/tokscale-core/src/aggregate/parity_tests.rs b/crates/tokscale-core/src/aggregate/parity_tests.rs index b601a2bed..e38c5e21e 100644 --- a/crates/tokscale-core/src/aggregate/parity_tests.rs +++ b/crates/tokscale-core/src/aggregate/parity_tests.rs @@ -893,8 +893,14 @@ fn contract_tui_workspace_provider_daily_and_streaks() { let daily_model = claude_source .models .values() - .find(|model| model.display_name == "repo-a / claude-sonnet-4") - .expect("workspace daily model display"); + .find(|model| model.model_id == "claude-sonnet-4") + .expect("workspace daily model"); + assert_eq!(daily_model.display_name, "claude-sonnet-4"); + assert_eq!( + daily_model.workspace_key.as_deref(), + Some("/Users/alice/repo-a") + ); + assert_eq!(daily_model.workspace_label.as_deref(), Some("repo-a")); assert_eq!(daily_model.provider, "anthropic"); assert_eq!(daily_model.messages, 1); } diff --git a/crates/tokscale-core/src/aggregate/tui.rs b/crates/tokscale-core/src/aggregate/tui.rs index b94c5cc6a..fc0314618 100644 --- a/crates/tokscale-core/src/aggregate/tui.rs +++ b/crates/tokscale-core/src/aggregate/tui.rs @@ -27,47 +27,44 @@ fn positive_unified_token_total(tokens: &crate::TokenBreakdown) -> i64 { fn grouped_model_display_label( group_by: &GroupBy, - workspace_label: Option<&str>, session_id: Option<&str>, model: &str, ) -> String { match group_by { - GroupBy::WorkspaceModel => workspace_label - .map(|label| format!("{label} / {model}")) - .unwrap_or_else(|| model.to_string()), GroupBy::Session | GroupBy::ClientSession => session_id .map(|session_id| format!("{session_id} / {model}")) .unwrap_or_else(|| model.to_string()), - GroupBy::Model | GroupBy::ClientModel | GroupBy::ClientProviderModel => model.to_string(), + GroupBy::Model + | GroupBy::ClientModel + | GroupBy::ClientProviderModel + | GroupBy::WorkspaceModel => model.to_string(), } } fn daily_source_model_display_name( group_by: &GroupBy, - workspace_label: Option<&str>, session_id: Option<&str>, model: &str, ) -> String { match group_by { - GroupBy::WorkspaceModel => format!( - "{} / {model}", - workspace_label.expect("workspace model bucket has a display label") - ), GroupBy::Session | GroupBy::ClientSession => format!( "{} / {model}", session_id.expect("session model bucket has a session identity") ), - GroupBy::Model | GroupBy::ClientModel | GroupBy::ClientProviderModel => model.to_string(), + GroupBy::Model + | GroupBy::ClientModel + | GroupBy::ClientProviderModel + | GroupBy::WorkspaceModel => model.to_string(), } } -fn model_color_key(_group_by: &GroupBy, _provider_id: &str, model: &str) -> String { - // All GroupBy variants currently reduce to the bare model name. +fn model_color_key(model: &str) -> String { + // All GroupBy variants reduce to the bare model name (ADR 0026). model.to_string() } fn hourly_model_display_name(group_by: &GroupBy, model: &str) -> String { - grouped_model_display_label(group_by, None, None, model) + grouped_model_display_label(group_by, None, model) } /// Sanitize a message cost: non-finite/negative -> 0 (the TUI never shows debt). @@ -149,8 +146,11 @@ fn merge_daily_sources( .entry(model_key.clone()) .or_insert_with(|| DailyModelInfo { provider: model_info.provider.clone(), + model_id: model_info.model_id.clone(), display_name: model_info.display_name.clone(), color_key: model_info.color_key.clone(), + workspace_key: model_info.workspace_key.clone(), + workspace_label: model_info.workspace_label.clone(), tokens: UsageTokenBreakdown::default(), cost: 0.0, messages: 0, @@ -500,6 +500,7 @@ struct DailySourceBucket { struct DailyModelBucket { provider: Arc, + workspace_key: Option>, workspace_label: Option>, session_id: Option>, model: Arc, @@ -564,17 +565,15 @@ fn materialize_tui_model(mut bucket: TuiModelBucket) -> UsageModelEntry { fn materialize_daily_model(model: DailyModelBucket, group_by: &GroupBy) -> DailyModelInfo { let provider = model.provider.to_string(); - let display_name = daily_source_model_display_name( - group_by, - model.workspace_label.as_deref(), - model.session_id.as_deref(), - &model.model, - ); - let color_key = model_color_key(group_by, &provider, &model.model); + let display_name = + daily_source_model_display_name(group_by, model.session_id.as_deref(), &model.model); DailyModelInfo { provider, + model_id: model.model.to_string(), display_name, - color_key, + color_key: model_color_key(&model.model), + workspace_key: model.workspace_key.map(|key| key.to_string()), + workspace_label: model.workspace_label.map(|label| label.to_string()), tokens: model.tokens, cost: model.cost, messages: model.messages, @@ -609,11 +608,11 @@ fn materialize_daily(bucket: DailyBucket, group_by: &GroupBy) -> DailyUsage { } fn materialize_hourly_model(model: HourlyModelBucket, group_by: &GroupBy) -> HourlyModelInfo { - let provider = model.provider.to_string(); HourlyModelInfo { - provider: provider.clone(), + provider: model.provider.to_string(), + model_id: model.model.to_string(), display_name: hourly_model_display_name(group_by, &model.model), - color_key: model_color_key(group_by, &provider, &model.model), + color_key: model_color_key(&model.model), tokens: model.tokens, cost: model.cost, } @@ -780,16 +779,24 @@ impl TuiAcc { let model_info = source_entry .models .entry(daily_model_key) - .or_insert_with(|| DailyModelBucket { - provider: Arc::clone(&msg.provider_id), - workspace_label: (*group_by == GroupBy::WorkspaceModel) - .then(|| workspace_fields(msg).1), - session_id: matches!(group_by, GroupBy::Session | GroupBy::ClientSession) - .then(|| Arc::clone(&msg.session_id)), - model: Arc::clone(&msg.model_id), - tokens: UsageTokenBreakdown::default(), - cost: 0.0, - messages: 0, + .or_insert_with(|| { + let (workspace_key, workspace_label) = if *group_by == GroupBy::WorkspaceModel { + let (key, label) = workspace_fields(msg); + (key, Some(label)) + } else { + (None, None) + }; + DailyModelBucket { + provider: Arc::clone(&msg.provider_id), + workspace_key, + workspace_label, + session_id: matches!(group_by, GroupBy::Session | GroupBy::ClientSession) + .then(|| Arc::clone(&msg.session_id)), + model: Arc::clone(&msg.model_id), + tokens: UsageTokenBreakdown::default(), + cost: 0.0, + messages: 0, + } }); add_unified_tokens(&mut model_info.tokens, &msg.tokens); model_info.cost += msg_cost; @@ -1484,16 +1491,36 @@ mod tests { let daily_keys: Vec<_> = claude.models.keys().cloned().collect(); assert_eq!(daily_keys.len(), 2); assert_ne!(daily_keys[0], daily_keys[1]); - let daily_display_names: Vec<_> = claude + + // The workspace dimension travels in structured fields; display_name + // and model_id stay the bare canonical model (ADR 0026). + let daily_identities: Vec<_> = claude .models .values() - .map(|info| info.display_name.clone()) + .map(|info| { + ( + info.display_name.clone(), + info.model_id.clone(), + info.workspace_key.clone(), + info.workspace_label.clone(), + ) + }) .collect(); assert_eq!( - daily_display_names, + daily_identities, vec![ - "repo-a / claude-sonnet-4.5".to_string(), - "repo-b / claude-sonnet-4.5".to_string() + ( + "claude-sonnet-4.5".to_string(), + "claude-sonnet-4.5".to_string(), + Some("/repo-a".to_string()), + Some("repo-a".to_string()), + ), + ( + "claude-sonnet-4.5".to_string(), + "claude-sonnet-4.5".to_string(), + Some("/repo-b".to_string()), + Some("repo-b".to_string()), + ), ] ); } @@ -1544,10 +1571,69 @@ mod tests { assert_eq!( display_names, vec![ - "demo / claude-sonnet-4.5".to_string(), - "demo / claude-sonnet-4.5".to_string() + "claude-sonnet-4.5".to_string(), + "claude-sonnet-4.5".to_string() ] ); + let workspace_keys: Vec<_> = claude + .models + .values() + .map(|info| info.workspace_key.clone()) + .collect(); + assert_eq!( + workspace_keys, + vec![ + Some("/srv/team-a/demo".to_string()), + Some("/srv/team-b/demo".to_string()) + ] + ); + } + + #[test] + fn daily_model_identity_fields_follow_the_group_by_contract() { + let loader = TuiUsageHarness; + for group_by in [ + GroupBy::Model, + GroupBy::ClientModel, + GroupBy::ClientProviderModel, + GroupBy::WorkspaceModel, + ] { + let usage = loader + .aggregate_messages( + vec![make_workspace_message( + "claude", + "claude-sonnet-4.5", + "anthropic", + "session-1", + 1.0, + Some("/repo-a"), + Some("repo-a"), + )], + &group_by, + ) + .unwrap(); + + let models = &usage.daily[0].source_breakdown["claude"].models; + assert_eq!(models.len(), 1); + let info = models.values().next().unwrap(); + assert_eq!(info.model_id, "claude-sonnet-4.5"); + assert_eq!(info.color_key, "claude-sonnet-4.5"); + assert_eq!(info.display_name, "claude-sonnet-4.5"); + if group_by == GroupBy::WorkspaceModel { + assert_eq!(info.workspace_key.as_deref(), Some("/repo-a")); + assert_eq!(info.workspace_label.as_deref(), Some("repo-a")); + } else { + assert_eq!(info.workspace_key, None); + assert_eq!(info.workspace_label, None); + } + + let hourly = &usage.hourly[0].models; + assert_eq!(hourly.len(), 1); + assert_eq!( + hourly.values().next().unwrap().model_id, + "claude-sonnet-4.5" + ); + } } #[test] @@ -1938,12 +2024,14 @@ mod tests { assert_eq!(daily.len(), 2); let first_daily = &daily["v1|cpm|1:a3:b:c1:d"]; assert_eq!(first_daily.provider, "b:c"); + assert_eq!(first_daily.model_id, "d"); assert_eq!(first_daily.display_name, "d"); assert_eq!(first_daily.tokens.total(), 10); assert_eq!(first_daily.cost, 10.0); assert_eq!(first_daily.messages, 1); let second_daily = &daily["v1|cpm|1:a1:b3:c:d"]; assert_eq!(second_daily.provider, "b"); + assert_eq!(second_daily.model_id, "c:d"); assert_eq!(second_daily.display_name, "c:d"); assert_eq!(second_daily.tokens.total(), 20); assert_eq!(second_daily.cost, 20.0); @@ -1953,11 +2041,13 @@ mod tests { assert_eq!(hourly.len(), 2); let first_hourly = &hourly["v1|pm|3:b:c1:d"]; assert_eq!(first_hourly.provider, "b:c"); + assert_eq!(first_hourly.model_id, "d"); assert_eq!(first_hourly.display_name, "d"); assert_eq!(first_hourly.tokens.total(), 10); assert_eq!(first_hourly.cost, 10.0); let second_hourly = &hourly["v1|pm|1:b3:c:d"]; assert_eq!(second_hourly.provider, "b"); + assert_eq!(second_hourly.model_id, "c:d"); assert_eq!(second_hourly.display_name, "c:d"); assert_eq!(second_hourly.tokens.total(), 20); assert_eq!(second_hourly.cost, 20.0); diff --git a/crates/tokscale-core/src/usage_views.rs b/crates/tokscale-core/src/usage_views.rs index a917ef6a7..1bbcbd327 100644 --- a/crates/tokscale-core/src/usage_views.rs +++ b/crates/tokscale-core/src/usage_views.rs @@ -94,8 +94,16 @@ pub struct DailyModelInfo { /// provider was seen first and is **not** authoritative. Only treat it as /// exact when `group_by == GroupBy::ClientProviderModel`. pub provider: String, + /// Bare canonical model ID: the authoritative model identity (ADR 0026). + pub model_id: String, + /// Pure display label; never carries the workspace dimension. Session + /// groupings still prefix the session id ("session / model"). pub display_name: String, + /// Pure color key for the color path; not a semantic identity. pub color_key: String, + /// Workspace dimension, populated only under `GroupBy::WorkspaceModel`. + pub workspace_key: Option, + pub workspace_label: Option, pub tokens: UsageTokenBreakdown, pub cost: f64, pub messages: u64, @@ -121,6 +129,8 @@ pub struct DailyUsage { #[derive(Debug, Clone)] pub struct HourlyModelInfo { pub provider: String, + /// Bare canonical model ID: the authoritative model identity (ADR 0026). + pub model_id: String, pub display_name: String, pub color_key: String, pub tokens: UsageTokenBreakdown, diff --git a/docs/adr/0026-group-by-view-scope-contract.md b/docs/adr/0026-group-by-view-scope-contract.md new file mode 100644 index 000000000..d315cf2d5 --- /dev/null +++ b/docs/adr/0026-group-by-view-scope-contract.md @@ -0,0 +1,82 @@ +# ADR 0026: Group By is a view-scope projection with an explicit model identity contract + +Status: Accepted + +## Context + +The TUI `GroupBy` selector (`GroupBy::Model`, `ClientModel`, +`ClientProviderModel`, `WorkspaceModel`; `Session` and `ClientSession` exist +in core but are not exposed) rebuilds the whole `UsageData` when the user +switches grouping. The authoritative numbers — totals, per-day and per-hour +aggregates, the contribution graph, and streaks — do not depend on the +grouping, but the model identity carried by the view types did: the +canonical model identity was smuggled through `DailyModelInfo.color_key`, +`display_name` grew a `"workspace / model"` prefix under +`GroupBy::WorkspaceModel`, and the workspace dimension had no structured +field on the daily/hourly model entries. Consumers that needed the canonical +model (Overview chart, Stats top-model ranking, snapshot, footer model +count) each re-derived it with their own +`color_key → display_name → map_key` fallback chain, so the storage key +(`GroupedModelKey::map_key`, a length-prefixed internal encoding) was acting +as a last-resort user-visible identity. + +That is exactly the shape ADR 0001 warns about: an implicit contract where a +color concern and a storage encoding silently double as semantic identity. + +## Decision + +Group By is a display projection of the Models-class tables. Switching it +changes how model rows are keyed and labeled; it must not change any +authoritative number. + +**Projection classification.** Every projection of `UsageData` is either: + +- **group-keyed** — reshaped by the grouping: `UsageData.models` (the Models + table) and the per-source model sub-buckets inside `daily`/`hourly` and + the period views derived from them; or +- **group-agnostic** — invariant under grouping: day/hour totals, `agents`, + the contribution graph, streaks, subscription usage, sessions, and every + "Top Model" ranking. + +**Canonical ranking.** Every "Top Model" ranking (Stats +`rank_canonical_models`, Overview chart aggregation, overview snapshot, +footer model count) groups by the bare canonical model ID read from +`model_id`, regardless of the active grouping. A WorkspaceModel projection +and a Model projection of the same data must produce identical rankings. + +**Identity triad.** Model-carrying view entries keep three separate fields +with disjoint duties: + +- `model_id` — the bare canonical model ID. The authoritative semantic + identity; the only field ranking and grouping consumers may key on. +- `display_name` — a pure label for rendering. It never carries the + workspace dimension. (Session groupings still prefix the session id; that + dimension is out of scope for this contract.) +- `color_key` — a pure color key for the color path (`model_color_for`). It + is not an identity source and must not be read as one. + +**Storage keys are not identity.** `GroupedModelKey::map_key` (the `v1|…` +length-prefixed encoding) exists to make internal buckets collision-free. +It must not appear as a user-visible identity, and no consumer may fall back +to it when deriving the canonical model. + +**Dimensions are structured fields.** A grouping dimension such as workspace +travels in dedicated fields (`workspace_key`, `workspace_label` on +`DailyModelInfo`, populated only under `GroupBy::WorkspaceModel`) and is +never concatenated into a label string. Exports (`models` CLI JSON, TUI +export) emit the grouping (`groupBy`) and the dimension fields +(`workspaceKey`/`workspaceLabel`) so a payload is self-describing. + +## Consequences + +- `DailyModelInfo` and `HourlyModelInfo` carry `model_id`; the TUI disk + cache schema is bumped so pre-identity cache files miss on the schema + version check instead of deserializing into a degraded shape. +- The `color_key → display_name → map_key` fallback chains in the TUI + consumers are deleted; ranking code reads `model_id` directly, and the + color path keeps reading `color_key`. +- Under `GroupBy::WorkspaceModel`, the daily-detail Model column now shows + the bare model name; presenting the workspace dimension in that table is a + separate, deliberate UI change that consumes the structured fields. +- Any future grouping dimension follows the same rule: a structured field on + the view entry plus an export field, never a label prefix. From f15fd7de614d90a188c26efc5b9f002493097a27 Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:20:09 +0800 Subject: [PATCH 2/4] fix(tui): hide group-by footer hint on tabs that ignore the key --- crates/tokscale-cli/src/tui/app.rs | 19 ++++---- crates/tokscale-cli/src/tui/ui/footer.rs | 61 ++++++++++++++++++++---- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 522501fe7..0a2aeb677 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -1036,14 +1036,7 @@ impl App { }; self.reset_hourly_view_interaction(); } - // Group By only reshapes the group-keyed projections (ADR 0026): - // Models plus the Daily/Monthly/Weekly tables built from them. - KeyCode::Char('g') - if matches!( - self.current_tab, - Tab::Models | Tab::Daily | Tab::Monthly | Tab::Weekly - ) => - { + KeyCode::Char('g') if self.group_by_applies_to_current_tab() => { self.open_group_by_picker(); } KeyCode::Char('u') if self.current_tab == Tab::Usage => { @@ -1700,6 +1693,16 @@ impl App { out } + /// Group By only reshapes the group-keyed projections (ADR 0026): + /// Models plus the Daily/Monthly/Weekly tables built from them. The + /// picker and its footer hint apply only on those tabs. + pub fn group_by_applies_to_current_tab(&self) -> bool { + matches!( + self.current_tab, + Tab::Models | Tab::Daily | Tab::Monthly | Tab::Weekly + ) + } + fn open_group_by_picker(&mut self) { use super::ui::dialog::GroupByPickerDialog; let dialog = diff --git a/crates/tokscale-cli/src/tui/ui/footer.rs b/crates/tokscale-cli/src/tui/ui/footer.rs index fbbe0c7aa..bcdddd25f 100644 --- a/crates/tokscale-cli/src/tui/ui/footer.rs +++ b/crates/tokscale-cli/src/tui/ui/footer.rs @@ -336,15 +336,19 @@ fn help_row_line(app: &App) -> Line<'static> { Span::styled("d/t/c", Style::default().fg(Color::Blue)), Span::styled("·", Style::default().fg(app.theme.muted)), Span::styled("[s]", Style::default().fg(Color::Cyan)), - Span::styled("·", Style::default().fg(app.theme.muted)), - Span::styled("[g]", Style::default().fg(Color::Cyan)), + ]; + if app.group_by_applies_to_current_tab() { + spans.push(Span::styled("·", Style::default().fg(app.theme.muted))); + spans.push(Span::styled("[g]", Style::default().fg(Color::Cyan))); + } + spans.extend([ Span::styled("·", Style::default().fg(app.theme.muted)), Span::styled("[p]", Style::default().fg(Color::Magenta)), Span::styled("·", Style::default().fg(app.theme.muted)), Span::styled("[r]", Style::default().fg(Color::Yellow)), Span::styled("·", Style::default().fg(app.theme.muted)), Span::styled("q", Style::default().fg(app.theme.muted)), - ]; + ]); if app.current_tab == Tab::Daily { spans.push(Span::styled("·", Style::default().fg(app.theme.muted))); if app.is_daily_detail_active() { @@ -421,11 +425,13 @@ fn help_row_line(app: &App) -> Line<'static> { "[s:sources]", Style::default().fg(Color::Cyan), )); - spans.push(Span::styled(" ", Style::default())); - spans.push(Span::styled( - format!("[g:{}]", app.group_by.borrow()), - Style::default().fg(Color::Cyan), - )); + if app.group_by_applies_to_current_tab() { + spans.push(Span::styled(" ", Style::default())); + spans.push(Span::styled( + format!("[g:{}]", app.group_by.borrow()), + Style::default().fg(Color::Cyan), + )); + } spans.push(Span::styled(" • ", Style::default().fg(app.theme.muted))); spans.push(Span::styled( format!("[p:{}]", app.theme.name.as_str()), @@ -715,6 +721,45 @@ mod tests { assert!(text.contains("·e·q")); } + #[test] + fn group_by_hint_only_shows_on_group_keyed_tabs() { + for tab in [Tab::Models, Tab::Daily, Tab::Monthly, Tab::Weekly] { + let text = line_text(help_row_line(&make_app_on(tab))); + assert!(text.contains("[g:"), "expected [g: hint on {tab:?}"); + } + for tab in [ + Tab::Overview, + Tab::Stats, + Tab::Hourly, + Tab::Usage, + Tab::Sessions, + Tab::Agents, + ] { + let text = line_text(help_row_line(&make_app_on(tab))); + assert!(!text.contains("[g:"), "unexpected [g: hint on {tab:?}"); + } + } + + #[test] + fn narrow_group_by_hint_only_shows_on_group_keyed_tabs() { + for (tab, expected) in [ + (Tab::Models, true), + (Tab::Daily, true), + (Tab::Monthly, true), + (Tab::Weekly, true), + (Tab::Overview, false), + (Tab::Stats, false), + (Tab::Hourly, false), + (Tab::Sessions, false), + (Tab::Agents, false), + ] { + let mut app = make_app_on(tab); + app.terminal_width = 50; + let text = line_text(help_row_line(&app)); + assert_eq!(text.contains("[g]"), expected, "tab {tab:?}"); + } + } + #[test] fn usage_status_row_uses_subscription_check_clock() { let mut app = make_app_on(Tab::Overview); From 71afcce50fc04946ba7348bec8cc06c0f561327f Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:25:53 +0800 Subject: [PATCH 3/4] fix(tui): tighten group-by follow-ups in sessions, export, and cache - fall back to workspace_key in the Models workspace column - retry sessions when a health-forced reload rides a group switch - label exports with the grouping of the loaded projection - reject current-schema cache entries missing modelId --- crates/tokscale-cli/src/tui/app.rs | 34 ++++- crates/tokscale-cli/src/tui/cache.rs | 152 +++++++++++++++++++++++ crates/tokscale-cli/src/tui/mod.rs | 54 ++++++-- crates/tokscale-cli/src/tui/ui/models.rs | 24 +++- 4 files changed, 253 insertions(+), 11 deletions(-) diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 0a2aeb677..7662b8eac 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -408,6 +408,11 @@ pub struct App { /// Set of clients currently selected in the source picker. pub enabled_clients: Rc>>, pub group_by: Rc>, + /// The grouping the currently loaded `data` was projected with. The + /// picker writes `group_by` immediately, but `data` only switches to the + /// new projection once the background reload lands, so exports must use + /// this value to stay consistent with the rows they contain. + pub data_group_by: tokscale_core::GroupBy, pub sort_field: SortField, pub sort_direction: SortDirection, tab_sort_state: HashMap, @@ -580,6 +585,7 @@ impl App { data_loader, enabled_clients: Rc::new(RefCell::new(enabled_clients)), group_by: Rc::new(RefCell::new(super::cache::TUI_DEFAULT_GROUP_BY)), + data_group_by: super::cache::TUI_DEFAULT_GROUP_BY, sort_field, sort_direction, tab_sort_state: HashMap::new(), @@ -1974,6 +1980,12 @@ impl App { } } + /// The grouping that matches the currently loaded `data` projection — + /// not a pending picker selection whose reload has not landed yet. + fn export_group_by(&self) -> tokscale_core::GroupBy { + self.data_group_by.clone() + } + fn export_to_json(&mut self) { let filename = format!( "tokscale-export-{}.json", @@ -1981,7 +1993,7 @@ impl App { ); let export_dir = crate::paths::get_config_dir().join("exports"); let path = export_dir.join(filename); - let group_by = self.group_by.borrow().clone(); + let group_by = self.export_group_by(); match super::export::build_export_json(&self.data, &group_by) { Ok(json) => match std::fs::create_dir_all(&export_dir) @@ -3994,6 +4006,26 @@ mod tests { assert!(app.reload_group_only); } + #[test] + fn export_group_by_tracks_loaded_data_not_pending_picker_selection() { + let mut app = make_app(); + // The picker writes `group_by` immediately, but `data` still holds the + // previous projection until the background reload lands; exports must + // describe the loaded rows. + *app.group_by.borrow_mut() = tokscale_core::GroupBy::ClientModel; + + assert_eq!( + app.export_group_by(), + crate::tui::cache::TUI_DEFAULT_GROUP_BY + ); + + app.data_group_by = tokscale_core::GroupBy::WorkspaceModel; + assert_eq!( + app.export_group_by(), + tokscale_core::GroupBy::WorkspaceModel + ); + } + #[test] fn test_source_picker_reload_is_not_marked_group_only() { let mut app = make_app(); diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index c30af8e52..14dda01c1 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -1009,6 +1009,10 @@ pub fn load_cache( Err(_) => return CacheResult::Miss, }; + if cached_models_missing_identity(&data) { + return CacheResult::Miss; + } + // Preserve the degraded report for immediate rendering, but force the // caller to rescan. A locked database or interrupted read can recover // without changing the source inventory fingerprint. Completed scans @@ -1031,6 +1035,23 @@ pub fn load_cache( } } +/// Since schema 38, `modelId` is the authoritative model identity (ADR 0026), +/// but the cached field still deserializes with `#[serde(default)]`. A cache +/// written without it would load with an empty id and merge unrelated entries +/// under the empty key downstream, so treat it as a miss and rescan. +fn cached_models_missing_identity(data: &UsageData) -> bool { + data.daily + .iter() + .flat_map(|day| day.source_breakdown.values()) + .flat_map(|source| source.models.values()) + .any(|model| model.model_id.is_empty()) + || data + .hourly + .iter() + .flat_map(|hour| hour.models.values()) + .any(|model| model.model_id.is_empty()) +} + /// Determine whether the cached client key exactly matches the current TUI request. fn cache_clients_match_exact( enabled_clients: &HashSet, @@ -2048,6 +2069,135 @@ mod tests { } } + /// Schema 38 made `modelId` the authoritative identity, but the cached + /// field is `#[serde(default)]`, so a file missing it would load with an + /// empty id and merge unrelated entries under the empty key. Such a cache + /// must miss instead (ADR 0026). + fn write_identity_cache_without_model_id(data: &str) { + let cache_path = cache_file().unwrap(); + fs::create_dir_all(cache_path.parent().unwrap()).unwrap(); + let json = format!( + r#"{{ + "schemaVersion": 38, + "timestamp": 9999999999999, + "enabledClients": ["claude"], + "groupBy": "model", + "reportScope": {{ + "resolvedHomeDir": "", + "useEnvRoots": false, + "since": null, + "until": null, + "year": null + }}, + "sourceInventorySignature": [90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90], + "data": {data} +}}"# + ); + fs::write(&cache_path, json).unwrap(); + } + + #[test] + #[serial] + fn test_load_cache_misses_when_daily_model_lacks_model_id() { + let temp_dir = TempDir::new().unwrap(); + let previous_home = env::var_os("HOME"); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + write_identity_cache_without_model_id( + r#"{ + "models": [], + "agents": [], + "daily": [{ + "date": "2026-07-11", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "sourceBreakdown": [["claude", { + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "models": [["v1|m|5:model", { + "provider": "anthropic", + "displayName": "model", + "colorKey": "model", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "messages": 1 + }]] + }]], + "messageCount": 1, + "turnCount": 1 + }], + "hourly": [], + "graph": null, + "totalTokens": 1, + "totalCost": 0.1, + "currentStreak": 1, + "longestStreak": 1 + }"#, + ); + + let clients = make_filters(&[ClientId::Claude]); + assert!(matches!( + load_cache(&clients, &GroupBy::Model, &CacheReportScope::default()), + CacheResult::Miss + )); + + match previous_home { + Some(home) => unsafe { env::set_var("HOME", home) }, + None => unsafe { env::remove_var("HOME") }, + } + } + + #[test] + #[serial] + fn test_load_cache_misses_when_hourly_model_lacks_model_id() { + let temp_dir = TempDir::new().unwrap(); + let previous_home = env::var_os("HOME"); + unsafe { + env::set_var("HOME", temp_dir.path()); + } + + write_identity_cache_without_model_id( + r#"{ + "models": [], + "agents": [], + "daily": [], + "hourly": [{ + "datetime": "2026-07-11 10:00:00", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1, + "clients": ["claude"], + "models": [["v1|m|5:model", { + "provider": "anthropic", + "displayName": "model", + "colorKey": "model", + "tokens": {"input": 1, "output": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}, + "cost": 0.1 + }]], + "messageCount": 1, + "turnCount": 1 + }], + "graph": null, + "totalTokens": 1, + "totalCost": 0.1, + "currentStreak": 1, + "longestStreak": 1 + }"#, + ); + + let clients = make_filters(&[ClientId::Claude]); + assert!(matches!( + load_cache(&clients, &GroupBy::Model, &CacheReportScope::default()), + CacheResult::Miss + )); + + match previous_home { + Some(home) => unsafe { env::set_var("HOME", home) }, + None => unsafe { env::remove_var("HOME") }, + } + } + #[test] #[serial] fn test_load_cache_misses_when_report_scope_differs() { @@ -2548,6 +2698,7 @@ mod tests { "claude-sonnet-4", { "provider": "anthropic", + "modelId": "claude-sonnet-4", "displayName": "claude-sonnet-4", "colorKey": "claude-sonnet-4", "tokens": { @@ -2577,6 +2728,7 @@ mod tests { "claude-sonnet-4", { "provider": "anthropic", + "modelId": "claude-sonnet-4", "displayName": "claude-sonnet-4", "colorKey": "claude-sonnet-4", "tokens": { diff --git a/crates/tokscale-cli/src/tui/mod.rs b/crates/tokscale-cli/src/tui/mod.rs index c1f0b906c..a38fb711a 100644 --- a/crates/tokscale-cli/src/tui/mod.rs +++ b/crates/tokscale-cli/src/tui/mod.rs @@ -84,6 +84,10 @@ enum BackgroundLoad { Loaded { data: Box, digest: u64, + /// The grouping this `data` projection was aggregated with; the App + /// records it so exports describe the loaded rows, not a pending + /// picker selection. + group_by: tokscale_core::GroupBy, source_inventory_signature: tokscale_core::SourceInventorySignature, pricing_diagnostics: Vec, cache_persistence_warning: Option, @@ -110,9 +114,16 @@ fn refresh_session_data( /// Sessions are group-agnostic (ADR 0026): a grouping-triggered reload /// re-aggregates the usage projection but must not force a Sessions rescan; -/// session refresh follows source-digest changes instead. -fn session_reload_force(force: bool, group_only_reload: bool) -> bool { - force && !group_only_reload +/// session refresh follows source-digest changes instead. When health still +/// requires a source retry, the source gets rescanned anyway, so the Sessions +/// snapshot must not miss that recovery attempt — a session-only change does +/// not move the inventory digest. +fn session_reload_force( + force: bool, + group_only_reload: bool, + health: &tokscale_core::source_health::HealthReport, +) -> bool { + force && (!group_only_reload || health.requires_source_retry()) } fn load_background_data( @@ -142,6 +153,7 @@ fn load_background_data( result.map(|result| BackgroundLoad::Loaded { data: Box::new(result.data), digest: result.source_digest, + group_by: group_by.clone(), source_inventory_signature: result.source_inventory_signature, pricing_diagnostics: result.pricing_diagnostics, cache_persistence_warning: None, @@ -199,11 +211,13 @@ fn apply_background_result(app: &mut App, result: Result) { Ok(BackgroundLoad::Loaded { data, digest, + group_by, source_inventory_signature: _, pricing_diagnostics, cache_persistence_warning, }) => { app.update_data(*data); + app.data_group_by = group_by; app.last_source_digest = Some(digest); app.set_cache_persistence_warning(cache_persistence_warning); app.set_pricing_diagnostics(&pricing_diagnostics); @@ -463,8 +477,11 @@ fn run_loop_with_background( let force = should_force_source_reload(std::mem::take(&mut app.reload_force), &app.data.health); - let session_force = - session_reload_force(force, std::mem::take(&mut app.reload_group_only)); + let session_force = session_reload_force( + force, + std::mem::take(&mut app.reload_group_only), + &app.data.health, + ); let last_digest = app.last_source_digest; let tx = bg_tx.clone(); let clients = app.scan_clients(); @@ -730,10 +747,27 @@ mod tests { fn session_reload_force_skips_grouping_triggered_reloads() { // Grouping switches re-aggregate the usage projection but leave the // Sessions snapshot to the source-digest probe (ADR 0026). - assert!(!session_reload_force(true, true)); - assert!(session_reload_force(true, false)); - assert!(!session_reload_force(false, true)); - assert!(!session_reload_force(false, false)); + let healthy = tokscale_core::source_health::HealthReport::default(); + assert!(!session_reload_force(true, true, &healthy)); + assert!(session_reload_force(true, false, &healthy)); + assert!(!session_reload_force(false, true, &healthy)); + assert!(!session_reload_force(false, false, &healthy)); + } + + #[test] + fn session_reload_force_retries_sessions_when_health_requires_source_retry() { + // A degraded report forces the source rescan even on a grouping-only + // reload; the Sessions snapshot must join that retry because session + // edits do not move the inventory digest. + let degraded = tokscale_core::source_health::HealthReport { + failed_sources: 1, + complete: false, + ..Default::default() + }; + + assert!(session_reload_force(true, true, °raded)); + assert!(session_reload_force(true, false, °raded)); + assert!(!session_reload_force(false, true, °raded)); } #[test] @@ -863,6 +897,7 @@ mod tests { ..UsageData::default() }), digest, + group_by: tokscale_core::GroupBy::Model, source_inventory_signature: signature, pricing_diagnostics: Vec::new(), cache_persistence_warning: None, @@ -928,6 +963,7 @@ mod tests { ..UsageData::default() }), digest, + group_by: tokscale_core::GroupBy::Model, source_inventory_signature: signature, pricing_diagnostics: Vec::new(), cache_persistence_warning: Some( diff --git a/crates/tokscale-cli/src/tui/ui/models.rs b/crates/tokscale-cli/src/tui/ui/models.rs index ca8bfa649..999b69005 100644 --- a/crates/tokscale-cli/src/tui/ui/models.rs +++ b/crates/tokscale-cli/src/tui/ui/models.rs @@ -20,7 +20,12 @@ use crate::tui::app::{App, SortDirection, SortField}; use tokscale_core::GroupBy; fn workspace_label(model: &crate::tui::data::ModelUsage) -> &str { - workspace_label_or_unknown(model.workspace_label.as_deref()) + workspace_label_or_unknown( + model + .workspace_label + .as_deref() + .or(model.workspace_key.as_deref()), + ) } /// The Model column always shows the bare canonical model; under @@ -528,6 +533,23 @@ mod tests { assert_eq!(model_display_name(&model), "gpt-5"); } + #[test] + fn workspace_column_falls_back_to_key_when_label_missing() { + let model = crate::tui::data::ModelUsage { + model: "gpt-5".to_string(), + provider: "openai".to_string(), + client: "opencode".to_string(), + workspace_key: Some("/work/project".to_string()), + workspace_label: None, + tokens: crate::tui::data::TokenBreakdown::default(), + cost: 0.0, + performance: Default::default(), + session_count: 0, + }; + + assert_eq!(workspace_label(&model), "/work/project"); + } + #[test] fn leftover_width_does_not_expand_content_columns() { let fit = model_layout(180, 28, 32, 26); From 25d73726f88034f943402ba7662d9340e2dbc9bc Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:32:35 +0800 Subject: [PATCH 4/4] test(tui): derive identity-cache fixture schema from CACHE_SCHEMA_VERSION --- crates/tokscale-cli/src/tui/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index 14dda01c1..db45aee9f 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -2078,7 +2078,7 @@ mod tests { fs::create_dir_all(cache_path.parent().unwrap()).unwrap(); let json = format!( r#"{{ - "schemaVersion": 38, + "schemaVersion": {CACHE_SCHEMA_VERSION}, "timestamp": 9999999999999, "enabledClients": ["claude"], "groupBy": "model",