diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 21df5f814..2df97ef9a 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -28,7 +28,7 @@ jobs: bin_name: tokscale - host: ubuntu-latest target: x86_64-unknown-linux-gnu - build: cargo build --release -p tokscale-cli --target x86_64-unknown-linux-gnu + build: cargo zigbuild --release -p tokscale-cli --target x86_64-unknown-linux-gnu strip: strip target/x86_64-unknown-linux-gnu/release/tokscale bin_name: tokscale @@ -54,6 +54,19 @@ jobs: target/ key: cli-${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}-${{ hashFiles('Cargo.lock') }} + - uses: mlugg/setup-zig@v2 + if: ${{ contains(matrix.settings.target, 'linux') }} + with: + version: 0.13.0 + + - name: Install cargo-zigbuild + uses: taiki-e/install-action@v2 + if: ${{ contains(matrix.settings.target, 'linux') }} + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild + - name: Build CLI binary run: ${{ matrix.settings.build }} shell: bash diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 8a49c307c..cfc94a106 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -220,6 +220,8 @@ pub struct App { pub background_loading: bool, + pub blocking_loading: bool, + pub needs_reload: bool, pub dialog_stack: DialogStack, @@ -342,6 +344,7 @@ impl App { click_areas: Vec::new(), spinner_frame: 0, background_loading: false, + blocking_loading: false, needs_reload: false, dialog_stack, dialog_needs_reload, @@ -370,9 +373,38 @@ impl App { pub fn set_background_loading(&mut self, loading: bool) { self.background_loading = loading; + if !loading { + self.blocking_loading = false; + } // Don't set data.loading - let cached data remain visible during background refresh } + pub fn request_blocking_reload(&mut self) { + self.needs_reload = true; + self.blocking_loading = true; + } + + pub fn is_blocking_loading(&self) -> bool { + self.blocking_loading + || (!self.dialog_stack.is_active() && *self.dialog_needs_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 needs_blocking_reload { + self.request_blocking_reload(); + } + } + pub fn update_data(&mut self, data: UsageData) { self.data = data; self.data_version = self.data_version.saturating_add(1); @@ -455,10 +487,7 @@ impl App { self.needs_reload = true; } - if *self.dialog_needs_reload.borrow() { - *self.dialog_needs_reload.borrow_mut() = false; - self.needs_reload = true; - } + self.consume_dialog_reload_if_ready(); // Poll background usage fetch if let Some(ref rx) = self.usage_rx { @@ -493,6 +522,7 @@ impl App { if self.dialog_stack.is_active() { self.dialog_stack.handle_key(key.code); + self.consume_dialog_reload_if_ready(); return false; } @@ -643,6 +673,7 @@ impl App { pub fn handle_mouse_event(&mut self, event: MouseEvent) { if self.dialog_stack.is_active() { self.dialog_stack.handle_mouse(event); + self.consume_dialog_reload_if_ready(); return; } @@ -2948,6 +2979,63 @@ mod tests { ); } + #[test] + fn test_group_by_change_requests_blocking_reload() { + let mut app = make_app(); + *app.group_by.borrow_mut() = tokscale_core::GroupBy::ClientModel; + + app.handle_key_event(key(KeyCode::Char('g'))); + app.handle_key_event(key(KeyCode::Down)); + app.handle_key_event(key(KeyCode::Down)); + app.handle_key_event(key(KeyCode::Enter)); + + assert_eq!( + *app.group_by.borrow(), + tokscale_core::GroupBy::WorkspaceModel + ); + assert!(!app.dialog_stack.is_active()); + assert!(app.needs_reload); + assert!(app.blocking_loading); + assert!(app.is_blocking_loading()); + } + + #[test] + fn test_source_picker_waits_until_close_before_blocking_reload() { + let mut app = make_app(); + + app.handle_key_event(key(KeyCode::Char('s'))); + assert!(app.dialog_stack.is_active()); + + app.handle_key_event(key(KeyCode::Enter)); + assert!(app.dialog_stack.is_active()); + assert!(!app.needs_reload); + assert!(!app.blocking_loading); + assert!(!app.is_blocking_loading()); + + app.on_tick(); + assert!(!app.needs_reload); + assert!(!app.blocking_loading); + + app.handle_key_event(key(KeyCode::Esc)); + + assert!(!app.dialog_stack.is_active()); + assert!(app.needs_reload); + assert!(app.blocking_loading); + assert!(app.is_blocking_loading()); + } + + #[test] + fn test_background_loading_clear_resets_blocking_loading() { + let mut app = make_app(); + + app.request_blocking_reload(); + app.set_background_loading(true); + app.set_background_loading(false); + + assert!(!app.background_loading); + assert!(!app.blocking_loading); + } + // ── handle_key_event: misc keys ───────────────────────────────── #[test] diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index 461bc44f1..718764a8f 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -22,7 +22,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 = 12; +const CACHE_SCHEMA_VERSION: u32 = 13; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1012,7 +1012,7 @@ mod tests { fs::write( &cache_path, r#"{ - "schemaVersion": 12, + "schemaVersion": 13, "timestamp": 9999999999999, "enabledClients": ["claude"], "includeSynthetic": false, @@ -1281,7 +1281,7 @@ mod tests { fs::create_dir_all(cache_path.parent().unwrap()).unwrap(); let mut cached: serde_json::Value = serde_json::from_str( r#"{ - "schemaVersion": 12, + "schemaVersion": 13, "timestamp": 0, "enabledClients": ["claude", "cursor"], "includeSynthetic": false, diff --git a/crates/tokscale-cli/src/tui/ui/daily.rs b/crates/tokscale-cli/src/tui/ui/daily.rs index 3bf157c1a..ff7d28ed9 100644 --- a/crates/tokscale-cli/src/tui/ui/daily.rs +++ b/crates/tokscale-cli/src/tui/ui/daily.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{ use super::model_usage_layout::{ display_width, model_usage_table_layout, ModelUsageColumn as DailyDetailColumn, - ModelUsageTableDensity as DailyDetailTableDensity, + ModelUsageLayoutProfile, ModelUsageTableDensity as DailyDetailTableDensity, ModelUsageTableLayout as DailyDetailTableLayout, DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, MODEL_MIN_WIDTH, }; @@ -46,6 +46,18 @@ enum DailyColumn { Cost, } +const DAILY_DETAIL_OPTIONAL_COLUMNS: [DailyDetailColumn; 9] = [ + DailyDetailColumn::Cost, + DailyDetailColumn::Source, + DailyDetailColumn::Provider, + DailyDetailColumn::Messages, + DailyDetailColumn::Input, + DailyDetailColumn::Output, + DailyDetailColumn::CacheRate, + DailyDetailColumn::CacheRead, + DailyDetailColumn::CacheWrite, +]; + #[derive(Debug, Clone, PartialEq, Eq)] struct DailyTableLayout { columns: Vec, @@ -208,16 +220,7 @@ fn daily_detail_table_layout( model_content_width, provider_content_width, source_content_width, - &[ - DailyDetailColumn::Source, - DailyDetailColumn::Provider, - DailyDetailColumn::Messages, - DailyDetailColumn::Input, - DailyDetailColumn::Output, - DailyDetailColumn::CacheRate, - DailyDetailColumn::CacheRead, - DailyDetailColumn::CacheWrite, - ], + ModelUsageLayoutProfile::standard(&DAILY_DETAIL_OPTIONAL_COLUMNS), ) } @@ -755,17 +758,13 @@ mod tests { } #[test] - fn very_narrow_daily_detail_layout_keeps_model_tokens_and_cost() { + fn very_narrow_daily_detail_layout_keeps_model_and_tokens() { let layout = daily_detail_table_layout(54, true, 80, 56, 40); assert_eq!(layout.density, DailyDetailTableDensity::VeryCompact); assert_eq!( layout.columns, - vec![ - DailyDetailColumn::Model, - DailyDetailColumn::Total, - DailyDetailColumn::Cost, - ] + vec![DailyDetailColumn::Model, DailyDetailColumn::Total] ); assert_eq!(layout.model_width, MODEL_MAX_WIDTH as usize); } diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index 969fb9e75..6f5a01e5b 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -41,7 +41,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { header::render(frame, app, chunks[0]); - if app.data.loading && !app.background_loading { + if app.is_blocking_loading() || (app.data.loading && !app.background_loading) { render_loading(frame, app, chunks[1]); } else if let Some(ref error) = app.data.error { render_error(frame, app, chunks[1], error); 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 d6504fef9..18919db21 100644 --- a/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs +++ b/crates/tokscale-cli/src/tui/ui/model_usage_layout.rs @@ -7,6 +7,7 @@ const TABLE_COLUMN_SPACING: u16 = 1; 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; pub(crate) const DETAIL_PROVIDER_WIDTH: u16 = 8; pub(crate) const DETAIL_SOURCE_WIDTH: u16 = 12; @@ -40,6 +41,52 @@ pub(crate) enum ModelUsageColumn { Cost, } +pub(crate) const MODEL_USAGE_REQUIRED_COLUMNS: [ModelUsageColumn; 2] = + [ModelUsageColumn::Model, ModelUsageColumn::Total]; + +pub(crate) const MODEL_USAGE_DISPLAY_ORDER: [ModelUsageColumn; 12] = [ + ModelUsageColumn::Model, + ModelUsageColumn::Source, + ModelUsageColumn::Provider, + ModelUsageColumn::Messages, + ModelUsageColumn::Input, + ModelUsageColumn::Output, + ModelUsageColumn::CacheRate, + ModelUsageColumn::CacheRead, + ModelUsageColumn::CacheWrite, + ModelUsageColumn::Total, + ModelUsageColumn::Cost, + ModelUsageColumn::Performance, +]; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ModelUsageLayoutProfile<'a> { + pub(crate) required_columns: &'a [ModelUsageColumn], + pub(crate) optional_columns_by_priority: &'a [ModelUsageColumn], + pub(crate) display_order: &'a [ModelUsageColumn], + pub(crate) model_max_width: u16, +} + +impl<'a> ModelUsageLayoutProfile<'a> { + pub(crate) fn standard(optional_columns_by_priority: &'a [ModelUsageColumn]) -> Self { + Self { + required_columns: &MODEL_USAGE_REQUIRED_COLUMNS, + optional_columns_by_priority, + display_order: &MODEL_USAGE_DISPLAY_ORDER, + model_max_width: MODEL_MAX_WIDTH, + } + } + + pub(crate) fn workspace(optional_columns_by_priority: &'a [ModelUsageColumn]) -> Self { + Self { + required_columns: &MODEL_USAGE_REQUIRED_COLUMNS, + optional_columns_by_priority, + display_order: &MODEL_USAGE_DISPLAY_ORDER, + model_max_width: WORKSPACE_MODEL_MAX_WIDTH, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ModelUsageTableLayout { pub(crate) columns: Vec, @@ -56,11 +103,24 @@ fn clamped_content_width(content_width: u16, min: u16, max: u16) -> u16 { content_width.clamp(min, max) } -fn core_model_width(table_width: u16, content_width: u16) -> u16 { - let desired = clamped_content_width(content_width, MODEL_MIN_WIDTH, MODEL_MAX_WIDTH); - let fixed_core_width = DETAIL_TOTAL_WIDTH - .saturating_add(DETAIL_COST_WIDTH) - .saturating_add(TABLE_COLUMN_SPACING.saturating_mul(2)); +fn core_model_width( + table_width: u16, + content_width: u16, + provider_width: u16, + source_width: u16, + profile: ModelUsageLayoutProfile<'_>, +) -> u16 { + let desired = clamped_content_width(content_width, MODEL_MIN_WIDTH, profile.model_max_width); + let fixed_core_width = profile + .required_columns + .iter() + .filter(|column| **column != ModelUsageColumn::Model) + .map(|column| column_width(*column, 0, provider_width, source_width)) + .fold(0u16, u16::saturating_add) + .saturating_add( + TABLE_COLUMN_SPACING + .saturating_mul(profile.required_columns.len().saturating_sub(1) as u16), + ); desired.min(table_width.saturating_sub(fixed_core_width)) } @@ -154,52 +214,58 @@ fn density_for_columns(columns: &[ModelUsageColumn]) -> ModelUsageTableDensity { ) }) { ModelUsageTableDensity::Detail - } else if columns.len() == 3 { + } else if columns.contains(&ModelUsageColumn::Cost) { ModelUsageTableDensity::Core } else { ModelUsageTableDensity::VeryCompact } } +fn display_rank(column: ModelUsageColumn, display_order: &[ModelUsageColumn]) -> usize { + display_order + .iter() + .position(|candidate| *candidate == column) + .unwrap_or(display_order.len()) +} + +fn insert_by_display_order( + candidate: &[ModelUsageColumn], + column: ModelUsageColumn, + display_order: &[ModelUsageColumn], +) -> usize { + let column_rank = display_rank(column, display_order); + candidate + .iter() + .position(|existing| display_rank(*existing, display_order) > column_rank) + .unwrap_or(candidate.len()) +} + pub(crate) fn model_usage_table_layout( table_width: u16, is_very_narrow: bool, model_content_width: u16, provider_content_width: u16, source_content_width: u16, - optional_columns: &[ModelUsageColumn], + profile: ModelUsageLayoutProfile<'_>, ) -> ModelUsageTableLayout { - let model_target_width = model_content_width.min(MODEL_MAX_WIDTH); - let model_width = core_model_width(table_width, model_content_width); let provider_width = provider_content_width.max(DETAIL_PROVIDER_WIDTH); let source_width = source_content_width.max(DETAIL_SOURCE_WIDTH); - let required_columns = [ - ModelUsageColumn::Model, - ModelUsageColumn::Total, - ModelUsageColumn::Cost, - ]; + let model_target_width = model_content_width.min(profile.model_max_width); + let model_width = core_model_width( + table_width, + model_content_width, + provider_width, + source_width, + profile, + ); let columns = if is_very_narrow || model_width < model_target_width { - required_columns.to_vec() + profile.required_columns.to_vec() } else { choose_priority_columns( table_width, - &required_columns, - optional_columns, - |candidate, column| { - if column == ModelUsageColumn::Performance { - candidate - .iter() - .position(|existing| *existing == ModelUsageColumn::Cost) - .unwrap_or(candidate.len()) - } else { - candidate - .iter() - .position(|existing| { - matches!(existing, ModelUsageColumn::Total | ModelUsageColumn::Cost) - }) - .unwrap_or(candidate.len()) - } - }, + profile.required_columns, + profile.optional_columns_by_priority, + |candidate, column| insert_by_display_order(candidate, column, profile.display_order), |candidate| layout_width(candidate, model_width, provider_width, source_width), ) }; @@ -249,6 +315,36 @@ pub(crate) fn model_usage_table_layout( mod tests { use super::*; + const MODEL_OPTIONAL_COLUMNS: [ModelUsageColumn; 9] = [ + ModelUsageColumn::Cost, + ModelUsageColumn::Source, + ModelUsageColumn::Provider, + ModelUsageColumn::Input, + ModelUsageColumn::Output, + ModelUsageColumn::CacheRate, + ModelUsageColumn::CacheRead, + ModelUsageColumn::CacheWrite, + ModelUsageColumn::Performance, + ]; + + fn standard_layout( + table_width: u16, + is_very_narrow: bool, + model_content_width: u16, + provider_content_width: u16, + source_content_width: u16, + optional_columns: &[ModelUsageColumn], + ) -> ModelUsageTableLayout { + model_usage_table_layout( + table_width, + is_very_narrow, + model_content_width, + provider_content_width, + source_content_width, + ModelUsageLayoutProfile::standard(optional_columns), + ) + } + fn width_at(widths: &[Constraint], index: usize) -> u16 { match widths[index] { Constraint::Length(width) => width, @@ -265,46 +361,31 @@ mod tests { #[test] fn very_narrow_layout_clamps_model_column_to_core_width() { - let layout = model_usage_table_layout(35, true, 80, 56, 40, &[]); + let layout = standard_layout(35, true, 80, 56, 40, &MODEL_OPTIONAL_COLUMNS); assert_eq!( layout.columns, - vec![ - ModelUsageColumn::Model, - ModelUsageColumn::Total, - ModelUsageColumn::Cost, - ] + vec![ModelUsageColumn::Model, ModelUsageColumn::Total] ); - assert_eq!(layout.model_width, 15); + assert_eq!(layout.model_width, 25); assert_eq!(table_width(&layout), 35); } #[test] fn core_layout_drops_optional_columns_before_overflowing_required_columns() { - let layout = model_usage_table_layout( - 35, - false, - 80, - 56, - 40, - &[ModelUsageColumn::Source, ModelUsageColumn::Provider], - ); + let layout = standard_layout(35, false, 80, 56, 40, &MODEL_OPTIONAL_COLUMNS); assert_eq!( layout.columns, - vec![ - ModelUsageColumn::Model, - ModelUsageColumn::Total, - ModelUsageColumn::Cost, - ] + vec![ModelUsageColumn::Model, ModelUsageColumn::Total] ); - assert_eq!(layout.model_width, 15); + assert_eq!(layout.model_width, 25); assert_eq!(table_width(&layout), 35); } #[test] - fn priority_stops_before_optional_columns_while_model_is_truncated() { - let layout = model_usage_table_layout( + fn priority_stops_before_cost_when_it_does_not_fit() { + let layout = standard_layout( 48, false, 80, @@ -315,25 +396,21 @@ mod tests { assert_eq!( layout.columns, - vec![ - ModelUsageColumn::Model, - ModelUsageColumn::Total, - ModelUsageColumn::Cost, - ] + vec![ModelUsageColumn::Model, ModelUsageColumn::Total] ); - assert!(layout.model_width < MODEL_MAX_WIDTH as usize); - assert_eq!(table_width(&layout), 48); + assert_eq!(table_width(&layout), 39); } #[test] fn lower_priority_columns_do_not_skip_a_blocked_column() { - let layout = model_usage_table_layout( + let layout = standard_layout( 70, false, 28, 8, 40, &[ + ModelUsageColumn::Cost, ModelUsageColumn::Source, ModelUsageColumn::Provider, ModelUsageColumn::Input, @@ -354,13 +431,17 @@ mod tests { #[test] fn short_model_content_does_not_reserve_extra_width_before_source() { - let layout = model_usage_table_layout( + let layout = standard_layout( 39, false, 5, 8, 12, - &[ModelUsageColumn::Source, ModelUsageColumn::Provider], + &[ + ModelUsageColumn::Cost, + ModelUsageColumn::Source, + ModelUsageColumn::Provider, + ], ); assert_eq!( @@ -375,4 +456,26 @@ mod tests { assert_eq!(layout.model_width, 5); assert_eq!(table_width(&layout), 38); } + + #[test] + fn workspace_profile_allows_a_wider_model_column() { + let layout = model_usage_table_layout( + 90, + false, + 80, + 8, + 40, + ModelUsageLayoutProfile::workspace(&MODEL_OPTIONAL_COLUMNS), + ); + + assert_eq!(layout.model_width, WORKSPACE_MODEL_MAX_WIDTH as usize); + assert_eq!( + layout.columns, + vec![ + ModelUsageColumn::Model, + ModelUsageColumn::Total, + ModelUsageColumn::Cost, + ] + ); + } } diff --git a/crates/tokscale-cli/src/tui/ui/models.rs b/crates/tokscale-cli/src/tui/ui/models.rs index 379e15239..4b394d9ca 100644 --- a/crates/tokscale-cli/src/tui/ui/models.rs +++ b/crates/tokscale-cli/src/tui/ui/models.rs @@ -5,8 +5,9 @@ use ratatui::widgets::{ use super::model_usage_layout::{ display_width, model_usage_table_layout, ModelUsageColumn as ModelsColumn, - ModelUsageTableDensity as ModelsTableDensity, ModelUsageTableLayout as ModelsTableLayout, - DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, MODEL_MIN_WIDTH, + ModelUsageLayoutProfile, ModelUsageTableDensity as ModelsTableDensity, + ModelUsageTableLayout as ModelsTableLayout, DETAIL_PROVIDER_WIDTH, DETAIL_SOURCE_WIDTH, + MODEL_MIN_WIDTH, }; use super::widgets::{ format_cache_hit_rate, format_cost, format_ms_per_1k, format_tokens, get_client_display_name, @@ -38,29 +39,39 @@ fn model_content_width(models: &[&crate::tui::data::ModelUsage], group_by: &Grou .unwrap_or(MODEL_MIN_WIDTH) } +const MODEL_OPTIONAL_COLUMNS: [ModelsColumn; 9] = [ + ModelsColumn::Cost, + ModelsColumn::Source, + ModelsColumn::Provider, + ModelsColumn::Input, + ModelsColumn::Output, + ModelsColumn::CacheRate, + ModelsColumn::CacheRead, + ModelsColumn::CacheWrite, + ModelsColumn::Performance, +]; + fn models_table_layout( table_width: u16, is_very_narrow: bool, model_content_width: u16, provider_content_width: u16, source_content_width: u16, + group_by: &GroupBy, ) -> ModelsTableLayout { + let profile = if *group_by == GroupBy::WorkspaceModel { + ModelUsageLayoutProfile::workspace(&MODEL_OPTIONAL_COLUMNS) + } else { + ModelUsageLayoutProfile::standard(&MODEL_OPTIONAL_COLUMNS) + }; + model_usage_table_layout( table_width, is_very_narrow, model_content_width, provider_content_width, source_content_width, - &[ - ModelsColumn::Source, - ModelsColumn::Provider, - ModelsColumn::Input, - ModelsColumn::Output, - ModelsColumn::CacheRate, - ModelsColumn::CacheRead, - ModelsColumn::CacheWrite, - ModelsColumn::Performance, - ], + profile, ) } @@ -177,6 +188,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { model_content_width, provider_content_width, source_content_width, + &group_by, ); let columns = table_layout.columns.clone(); let header = Row::new( @@ -297,7 +309,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; + use super::super::model_usage_layout::{MODEL_MAX_WIDTH, WORKSPACE_MODEL_MAX_WIDTH}; use super::*; fn length_at(widths: &[Constraint], index: usize) -> u16 { @@ -308,7 +320,7 @@ mod tests { } fn model_layout(table_width: u16, model: u16, provider: u16, source: u16) -> ModelsTableLayout { - models_table_layout(table_width, false, model, provider, source) + models_table_layout(table_width, false, model, provider, source, &GroupBy::Model) } fn workspace_model_layout( @@ -317,7 +329,14 @@ mod tests { provider: u16, source: u16, ) -> ModelsTableLayout { - models_table_layout(table_width, false, model, provider, source) + models_table_layout( + table_width, + false, + model, + provider, + source, + &GroupBy::WorkspaceModel, + ) } #[test] @@ -343,7 +362,7 @@ mod tests { #[test] fn narrow_model_layout_keeps_model_tokens_and_cost() { - let layout = models_table_layout(74, false, 80, 56, 40); + let layout = models_table_layout(74, false, 80, 56, 40, &GroupBy::Model); assert_eq!( layout.columns, @@ -354,12 +373,12 @@ mod tests { #[test] fn very_narrow_model_layout_still_keeps_tokens_before_cache_details() { - let layout = models_table_layout(54, true, 80, 56, 40); + let layout = models_table_layout(54, true, 80, 56, 40, &GroupBy::Model); assert_eq!(layout.density, ModelsTableDensity::VeryCompact); assert_eq!( layout.columns, - vec![ModelsColumn::Model, ModelsColumn::Total, ModelsColumn::Cost] + vec![ModelsColumn::Model, ModelsColumn::Total] ); assert!(!layout.columns.contains(&ModelsColumn::CacheRead)); } @@ -397,7 +416,7 @@ mod tests { 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 <= MODEL_MAX_WIDTH as usize); + assert!(wide.model_width <= WORKSPACE_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); @@ -438,6 +457,14 @@ mod tests { assert_eq!(length_at(&layout.widths, 2), 120); } + #[test] + fn workspace_model_column_uses_workspace_cap_on_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); + } + #[test] fn source_column_stops_growing_after_visible_content_fits() { let fit = model_layout(220, 28, 56, 26); diff --git a/crates/tokscale-cli/tests/cli_tests.rs b/crates/tokscale-cli/tests/cli_tests.rs index 60770e9f8..ad505f63e 100644 --- a/crates/tokscale-cli/tests/cli_tests.rs +++ b/crates/tokscale-cli/tests/cli_tests.rs @@ -356,6 +356,112 @@ fn create_codex_workspace_fixture_dir() -> TempDir { tmp } +fn create_mixed_workspace_fixture_dir() -> TempDir { + let tmp = TempDir::new().expect("failed to create temp dir"); + let base = tmp.path(); + prime_pricing_cache(base); + + let workspace = "/Users/alice/shared-workspace"; + + let claude_session = base + .join(".claude") + .join("projects") + .join("-Users-alice-shared-workspace"); + fs::create_dir_all(&claude_session).unwrap(); + fs::write( + claude_session.join("claude-session.jsonl"), + format!( + "{}\n", + serde_json::json!({ + "type": "assistant", + "timestamp": "2026-01-01T00:00:01.000Z", + "cwd": workspace, + "message": { + "model": "gpt-5.4", + "usage": { + "input_tokens": 10, + "output_tokens": 5 + } + } + }) + ), + ) + .unwrap(); + + let codex_sessions = base.join(".codex/sessions"); + fs::create_dir_all(&codex_sessions).unwrap(); + fs::write( + codex_sessions.join("codex-session.jsonl"), + format!( + "{}\n{}\n{}\n", + serde_json::json!({ + "type": "session_meta", + "payload": { + "source": "chat", + "cwd": workspace + } + }), + serde_json::json!({ + "type": "turn_context", + "payload": { + "model": "gpt-5.4" + } + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:02Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 20, + "cached_input_tokens": 0, + "output_tokens": 10 + } + } + } + }) + ), + ) + .unwrap(); + + let pi_sessions = base.join(".pi/agent/sessions"); + fs::create_dir_all(&pi_sessions).unwrap(); + fs::write( + pi_sessions.join("pi-session.jsonl"), + format!( + "{}\n{}\n", + serde_json::json!({ + "type": "session", + "id": "pi-session", + "timestamp": "2026-01-01T00:00:00.000Z", + "cwd": workspace + }), + serde_json::json!({ + "type": "message", + "id": "pi-msg", + "parentId": null, + "timestamp": "2026-01-01T00:00:03.000Z", + "message": { + "role": "assistant", + "model": "gpt-5.4", + "provider": "openai", + "usage": { + "input": 30, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 45 + } + } + }) + ), + ) + .unwrap(); + + tmp +} + fn create_opencode_workspace_fixture_dir() -> TempDir { let tmp = TempDir::new().expect("failed to create temp dir"); let base = tmp.path(); @@ -2278,6 +2384,48 @@ fn test_models_group_by_workspace_model_surfaces_workspace_fields_for_codex() { assert_eq!(entries[0]["model"].as_str().unwrap(), "gpt-5.4"); } +#[test] +fn test_models_group_by_workspace_model_merges_claude_codex_pi_by_cwd() { + let tmp = create_mixed_workspace_fixture_dir(); + let output = cmd_with_home(tmp.path()) + .args([ + "models", + "--json", + "--client", + "claude,codex,pi", + "--no-spinner", + ]) + .args(["--group-by", "workspace,model"]) + .output() + .unwrap(); + assert!(output.status.success(), "command failed: {:?}", output); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["groupBy"].as_str().unwrap(), "workspace,model"); + + let entries = json["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0]["workspaceKey"].as_str().unwrap(), + "/Users/alice/shared-workspace" + ); + assert_eq!( + entries[0]["workspaceLabel"].as_str().unwrap(), + "shared-workspace" + ); + assert_eq!(entries[0]["model"].as_str().unwrap(), "gpt-5.4"); + assert_eq!(entries[0]["input"].as_i64().unwrap(), 60); + assert_eq!(entries[0]["output"].as_i64().unwrap(), 30); + assert_eq!(entries[0]["messageCount"].as_i64().unwrap(), 3); + + let mut clients: Vec<_> = entries[0]["mergedClients"] + .as_str() + .unwrap() + .split(", ") + .collect(); + clients.sort_unstable(); + assert_eq!(clients, vec!["claude", "codex", "pi"]); +} + #[test] fn test_models_group_by_workspace_model_surfaces_workspace_fields_for_opencode() { let tmp = create_opencode_workspace_fixture_dir(); diff --git a/crates/tokscale-core/src/message_cache.rs b/crates/tokscale-core/src/message_cache.rs index fb197d794..a44bcff1e 100644 --- a/crates/tokscale-core/src/message_cache.rs +++ b/crates/tokscale-core/src/message_cache.rs @@ -10,7 +10,7 @@ use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; -const CACHE_SCHEMA_VERSION: u32 = 17; +const CACHE_SCHEMA_VERSION: u32 = 18; const CACHE_FILENAME: &str = "source-message-cache.bin"; const CACHE_LOCK_FILENAME: &str = "source-message-cache.lock"; const MAX_CACHE_FILE_BYTES: u64 = 256 * 1024 * 1024; diff --git a/crates/tokscale-core/src/sessions/claudecode.rs b/crates/tokscale-core/src/sessions/claudecode.rs index f110053d6..2eec9fa63 100644 --- a/crates/tokscale-core/src/sessions/claudecode.rs +++ b/crates/tokscale-core/src/sessions/claudecode.rs @@ -40,6 +40,8 @@ pub struct ClaudeEntry { /// Optional billing or routing provider emitted by wrappers around Claude Code. #[serde(rename = "providerId", alias = "provider_id", alias = "provider")] pub provider_id: Option, + /// Current working directory emitted by Claude Code for workspace identity. + pub cwd: Option, } /// Meta sidecar written next to nested-layout sidechain transcripts. @@ -62,6 +64,22 @@ impl CcMirrorVariantMetadata { } } +#[derive(Debug, Clone)] +struct ClaudeWorkspaceParts { + key: String, + label: Option, +} + +impl ClaudeWorkspaceParts { + fn into_options(self) -> (Option, Option) { + (Some(self.key), self.label) + } + + fn to_options(&self) -> (Option, Option) { + (Some(self.key.clone()), self.label.clone()) + } +} + #[derive(Debug, Deserialize)] pub struct ClaudeMessage { pub model: Option, @@ -401,6 +419,8 @@ pub fn parse_claude_file_with_cache_and_home( buffer.clear(); buffer.extend_from_slice(trimmed.as_bytes()); if let Ok(entry) = simd_json::from_slice::(&mut buffer) { + let entry_workspace = entry.cwd.as_deref().and_then(workspace_parts_from_key); + // Detect sidechain on the first parseable entry (any type). // All lines in a subagent file carry isSidechain: true. if !sidechain_detected { @@ -420,6 +440,11 @@ pub fn parse_claude_file_with_cache_and_home( } if entry.entry_type == "user" || entry.entry_type == "tool_result" { + let (context_workspace_key, context_workspace_label) = workspace_options_for_entry( + entry_workspace.as_ref(), + &workspace_key, + &workspace_label, + ); let tool_result_message = extract_claude_tool_result_message( trimmed, ClaudeToolResultContext { @@ -430,8 +455,8 @@ pub fn parse_claude_file_with_cache_and_home( default_provider_hint: metadata_provider_hint, session_id: &session_id, fallback_timestamp, - workspace_key: workspace_key.clone(), - workspace_label: workspace_label.clone(), + workspace_key: context_workspace_key, + workspace_label: context_workspace_label, sidechain_agent: sidechain_agent.clone(), }, ); @@ -453,6 +478,9 @@ pub fn parse_claude_file_with_cache_and_home( tool_message.tokens.input, tool_message.timestamp, ); + if let Some(workspace) = entry_workspace.as_ref() { + set_message_workspace(&mut messages[existing_idx], workspace); + } continue; } processed_hashes.insert(dedup_key.clone(), messages.len()); @@ -512,6 +540,9 @@ pub fn parse_claude_file_with_cache_and_home( parse_claude_entry_timestamp(entry.timestamp.as_deref()), pending_request_start_timestamp_ms, ); + if let Some(workspace) = entry_workspace.as_ref() { + set_message_workspace(&mut messages[existing_idx], workspace); + } if let Some(choice) = duplicate_provider_choice { update_claude_provider_id( &mut messages[existing_idx].provider_id, @@ -539,6 +570,9 @@ pub fn parse_claude_file_with_cache_and_home( parse_claude_entry_timestamp(entry.timestamp.as_deref()), pending_request_start_timestamp_ms, ); + if let Some(workspace) = entry_workspace.as_ref() { + set_message_workspace(&mut messages[existing_idx], workspace); + } if let Some(choice) = duplicate_provider_choice { update_claude_provider_id( &mut messages[existing_idx].provider_id, @@ -589,7 +623,12 @@ pub fn parse_claude_file_with_cache_and_home( ); unified.duration_ms = duration_ms; unified.agent = sidechain_agent.clone(); - unified.set_workspace(workspace_key.clone(), workspace_label.clone()); + let (message_workspace_key, message_workspace_label) = workspace_options_for_entry( + entry_workspace.as_ref(), + &workspace_key, + &workspace_label, + ); + unified.set_workspace(message_workspace_key, message_workspace_label); // Mark the first assistant response after a user message as a turn start if pending_turn_start { unified.is_turn_start = true; @@ -653,31 +692,54 @@ fn claude_workspace_from_path(path: &Path) -> (Option, Option) { for window in components.windows(3) { if window[0] == ".claude" && window[1] == "projects" { - let key = normalize_workspace_key(&window[2]); - let label = key.as_deref().and_then(workspace_label_from_key); - return (key, label); + return workspace_options_from_key(&window[2]); } } for window in components.windows(5) { if window[0] == ".cc-mirror" && window[2] == "config" && window[3] == "projects" { - let key = normalize_workspace_key(&window[4]); - let label = key.as_deref().and_then(workspace_label_from_key); - return (key, label); + return workspace_options_from_key(&window[4]); } } for window in components.windows(2).rev() { if window[0] == "projects" { - let key = normalize_workspace_key(&window[1]); - let label = key.as_deref().and_then(workspace_label_from_key); - return (key, label); + return workspace_options_from_key(&window[1]); } } (None, None) } +fn workspace_options_from_key(raw: &str) -> (Option, Option) { + workspace_parts_from_key(raw) + .map(ClaudeWorkspaceParts::into_options) + .unwrap_or((None, None)) +} + +fn workspace_parts_from_key(raw: &str) -> Option { + let key = normalize_workspace_key(raw)?; + let label = workspace_label_from_key(&key); + Some(ClaudeWorkspaceParts { key, label }) +} + +fn set_message_workspace(message: &mut UnifiedMessage, workspace: &ClaudeWorkspaceParts) { + let (workspace_key, workspace_label) = workspace.to_options(); + message.set_workspace(workspace_key, workspace_label); +} + +fn workspace_options_for_entry( + entry_workspace: Option<&ClaudeWorkspaceParts>, + fallback_key: &Option, + fallback_label: &Option, +) -> (Option, Option) { + if let Some(workspace) = entry_workspace { + workspace.to_options() + } else { + (fallback_key.clone(), fallback_label.clone()) + } +} + fn sanitize_cc_mirror_segment(raw: &str) -> String { let mut segment: String = raw .trim() @@ -2242,6 +2304,70 @@ mod tests { assert_eq!(messages[0].workspace_label, Some("myproject".to_string())); } + #[test] + fn test_workspace_metadata_prefers_entry_cwd_over_claude_project_path() { + let content = r#"{"type":"assistant","timestamp":"2024-12-01T10:00:00.000Z","cwd":"/home/travis/01-workspace/tokscale","message":{"model":"claude-3-5-sonnet","usage":{"input_tokens":100,"output_tokens":50}}}"#; + let (_dir, path) = create_project_file( + content, + "-home-travis-01-workspace-tokscale", + "session.jsonl", + ); + + let messages = parse_claude_file(&path); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].workspace_key, + Some("/home/travis/01-workspace/tokscale".to_string()) + ); + assert_eq!(messages[0].workspace_label, Some("tokscale".to_string())); + } + + #[test] + fn test_workspace_metadata_preserves_key_without_label() { + let (workspace_key, workspace_label) = workspace_options_from_key("/"); + + assert_eq!(workspace_key, Some("/".to_string())); + assert_eq!(workspace_label, None); + } + + #[test] + fn test_workspace_metadata_prefers_later_entry_cwd_on_dedupe() { + let content = r#"{"type":"assistant","timestamp":"2024-12-01T10:00:00.000Z","requestId":"req_late_cwd","message":{"id":"msg_late_cwd","model":"claude-3-5-sonnet","usage":{"input_tokens":100,"output_tokens":50}}} +{"type":"assistant","timestamp":"2024-12-01T10:00:01.000Z","requestId":"req_late_cwd","cwd":"/home/travis/01-workspace/tokscale","message":{"id":"msg_late_cwd","model":"claude-3-5-sonnet","usage":{"input_tokens":120,"output_tokens":70}}}"#; + let (_dir, path) = create_project_file( + content, + "-home-travis-01-workspace-tokscale", + "session.jsonl", + ); + + let messages = parse_claude_file(&path); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].tokens.input, 120); + assert_eq!(messages[0].tokens.output, 70); + assert_eq!( + messages[0].workspace_key, + Some("/home/travis/01-workspace/tokscale".to_string()) + ); + assert_eq!(messages[0].workspace_label, Some("tokscale".to_string())); + } + + #[test] + fn test_workspace_metadata_normalizes_entry_windows_cwd() { + let content = r#"{"type":"assistant","timestamp":"2024-12-01T10:00:00.000Z","cwd":"C:\\Users\\Travis\\Desktop","message":{"model":"claude-3-5-sonnet","usage":{"input_tokens":100,"output_tokens":50}}}"#; + let (_dir, path) = create_project_file(content, "C--Users-Travis-Desktop", "session.jsonl"); + + let messages = parse_claude_file(&path); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].workspace_key, + Some("C:/Users/Travis/Desktop".to_string()) + ); + assert_eq!(messages[0].workspace_label, Some("Desktop".to_string())); + } + #[test] fn test_wrapper_transcript_with_usage_is_parsed() { let content = r#"{"type":"user","timestamp":"2026-04-01T10:00:00.000Z","message":{"content":"Wrapped prompt"}} diff --git a/crates/tokscale-core/src/sessions/mod.rs b/crates/tokscale-core/src/sessions/mod.rs index 6ae448814..1db46a243 100644 --- a/crates/tokscale-core/src/sessions/mod.rs +++ b/crates/tokscale-core/src/sessions/mod.rs @@ -321,6 +321,18 @@ impl UnifiedMessage { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceMetadata { + pub key: String, + pub label: String, +} + +pub fn workspace_metadata_from_key(raw: &str) -> Option { + let key = normalize_workspace_key(raw)?; + let label = workspace_label_from_key(&key)?; + Some(WorkspaceMetadata { key, label }) +} + pub fn normalize_workspace_key(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -583,6 +595,17 @@ mod tests { ); } + #[test] + fn test_workspace_metadata_from_key_pairs_normalized_key_and_label() { + assert_eq!( + workspace_metadata_from_key(r"C:\Users\alice\repo\"), + Some(WorkspaceMetadata { + key: "C:/Users/alice/repo".to_string(), + label: "repo".to_string(), + }) + ); + } + #[test] fn test_normalize_agent_name() { assert_eq!(normalize_agent_name("OmO"), "Sisyphus"); diff --git a/scripts/check-release-workflow-safety.py b/scripts/check-release-workflow-safety.py index d18659bca..35c1f8bb5 100755 --- a/scripts/check-release-workflow-safety.py +++ b/scripts/check-release-workflow-safety.py @@ -163,14 +163,17 @@ def main() -> None: publish_build = by_target(matrix_settings(publish_lines, "build-cli-binary"), "publish build") native_build = by_target(matrix_settings(native_lines, "build"), "build-native") - if list(publish_build) != list(native_build): + unexpected_native_targets = [ + target for target in native_build if target not in publish_build + ] + if unexpected_native_targets: errors.append( - f"build matrix targets differ: publish={list(publish_build)}, build-native={list(native_build)}" + f"build-native matrix contains targets missing from publish: {unexpected_native_targets}" ) - for target, publish_entry in publish_build.items(): - native_entry = native_build.get(target) - if native_entry is None: + for target, native_entry in native_build.items(): + publish_entry = publish_build.get(target) + if publish_entry is None: continue for field in COMMON_BUILD_FIELDS: if publish_entry.get(field, "") != native_entry.get(field, ""):