diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e582c..45c98b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,20 @@ All notable changes to this project are documented in this file. ## Unreleased +## 0.4.3 - 2026-07-22 + - Added a persisted `n` shortcut to cycle display formatting through Classic, System Compact, and System Full for numbers, dates, times, and calendar labels. - Clarified token pair charts with the `TOKENS (TOTAL / NON-CACHED)` heading. - Added compact `K/M/B/T` notation for large dashboard token values in System Compact mode, including values inside vertical chart bars. System Full keeps those values expanded, while Classic output remains unchanged. -- Added mouse controls for selecting the visible statistic, chart timeframe, and chart orientation; clicking the footer cycles the display style on every screen. +- Added mouse controls for selecting the visible statistic, chart timeframe, chart orientation, screen, and display style. - Restored compact one-line control strips on the Usage and Activity screens and highlighted the padded group labels with the chart's darker cyan. - Removed duplicate shortcut hints from screen headers, leaving the complete shortcut reference in the footer and help overlay. - Added clickable screen tabs to the outer title and matching framed View/Projects controls to the Activity screen. +- System Full now groups expanded integers with regular spaces while retaining system-localized dates, times, and decimals. +- Added a compact `STYLE CLASS/SCOMP/SFULL` selector after the Usage bar controls and a bottom-border Quit action with a safe Yes/No confirmation dialog; the `q` shortcut opens the same dialog. +- Added the current locale-aware date and time to the `TODAY` card border. +- Vertical chart values now compact only when an individual bar is too narrow, with an exact locale-aware value tooltip on mouse hover. +- Exit confirmation can be disabled from the quit dialog, persists in `state.json`, and can be re-enabled through the confirmed checkbox beside `QUIT`. ## 0.3.6 - 2026-06-05 diff --git a/Cargo.lock b/Cargo.lock index d94ad9a..f29e785 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,7 +259,7 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "comon" -version = "0.4.2" +version = "0.4.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 1fd2d99..015db58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comon" -version = "0.4.2" +version = "0.4.3" edition = "2021" license = "Apache-2.0" diff --git a/README.md b/README.md index 4fceb41..7435250 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,12 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512 - `w` Toggle timeframe (Week/Month) - `f` Toggle layout (Horz/Vert) - `n` Cycle display formatting (Classic/System Compact/System Full) -- Mouse: click the top tabs to switch screens; click Tokens/Time/Runs, Week/Month, Horz/Vert, or Activity project-count controls; click the footer to cycle display formatting +- Mouse: click the top tabs, Usage/Activity controls (including the Usage style selector), or the bottom-right Quit action +- Mouse: hover a filled vertical chart bar to see its exact date and full locale-aware value - `s` / `F2` Switch between Usage and Session history - `r` / `F5` Refresh current screen - `?` Help overlay -- `Esc` / `q` Quit +- `q` Quit (with confirmation) - `Enter` / `y` Continue past "no sessions found" warning (when shown) - Session history: `Up` / `Down` / mouse wheel navigate, `Enter` / `Right` open project sessions, `Backspace` / `Left` / `Esc` go back @@ -342,7 +343,9 @@ CI also runs this check on each push and pull request via `.github/workflows/asc - Usage stats are derived from Codex session JSONL logs. If you have no session data yet, values will be empty. - Limits/credits require Codex App Server to start successfully (auth, environment, and a usable working directory). CoMon auto-detects `codex` on `PATH` and common Windows Codex App bundle locations; use `--codex-bin` or `--app-server-bin` when needed. - comon stores local app state in `~/.comon/state.json` by default (or `$COMON_HOME`, or `--comon-home`). -- Display formatting starts in Classic mode; press `n` to cycle through Classic, System Compact, and System Full. Both System modes use the operating system locale for numbers, dates, times, and calendar labels; Compact abbreviates dashboard token values, while Full keeps them expanded except for the pre-existing compact summary values. The choice is saved in `state.json` without changing stored data. +- Display formatting starts in Classic mode; press `n` or use `STYLE CLASS/SCOMP/SFULL` in the Usage controls to choose Classic, System Compact, or System Full. Both System modes use the operating system locale for dates, times, decimals, and calendar labels; Compact uses the detected thousands separator and abbreviates dashboard token values, while Full groups expanded integers with regular spaces. The choice is saved in `state.json` without changing stored data. +- Vertical chart labels preserve the selected style when they fit and compact only individual values that exceed their bar width. Hovering a filled bar shows the exact value. +- The quit dialog's `Don't show again` checkbox disables future `q`/`QUIT` confirmations after a confirmed exit. The checkbox beside `QUIT` shows that saved state; clicking it asks before enabling or disabling confirmation. - comon stores scan cache in `~/.comon/comon.db` to avoid rereading unchanged session files. - Large session logs are parsed incrementally with persisted parser offsets in `comon.db`; unchanged files are reused from cache. - If historical days look incomplete after adding old session files, run once with `--full-scan --scan-time-budget-ms 0` to force a full reparse and refresh cached summaries. diff --git a/docs/screenshots/usage-system-full.png b/docs/screenshots/usage-system-full.png new file mode 100644 index 0000000..e591746 Binary files /dev/null and b/docs/screenshots/usage-system-full.png differ diff --git a/src/app/mod.rs b/src/app/mod.rs index a4facb7..9c7a56e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -74,12 +74,19 @@ pub(crate) enum ActiveScreen { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum UiClickAction { SetScreen(ActiveScreen), + SetDisplayStyle(DisplayStyle), SetMetric(UsageMetric), SetRange(ChartRange), SetOrientation(ChartOrientation), DecreaseProjects, IncreaseProjects, - CycleDisplayStyle, + PromptQuit, + CancelQuit, + ConfirmQuit, + ToggleQuitDontAskAgain, + PromptQuitConfirmationPreference, + ConfirmQuitConfirmationPreference, + CancelQuitConfirmationPreference, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,8 +145,13 @@ pub(crate) struct AppState { pub(crate) workspace_path: Option, pub(crate) no_sessions_confirm_open: bool, pub(crate) no_sessions_confirm_dismissed: bool, + pub(crate) quit_confirm_open: bool, + pub(crate) quit_dont_ask_again: bool, + pub(crate) skip_quit_confirmation: bool, + pub(crate) quit_preference_prompt: Option, pub(crate) display_style: DisplayStyle, pub(crate) system_locale: SystemLocale, + pub(crate) mouse_position: Option<(u16, u16)>, pub(crate) ui_hit_targets: Vec, pub(crate) usage: Option, @@ -177,6 +189,7 @@ struct PersistedUiState { workspace_path: Option, no_sessions_confirm_dismissed: bool, display_style: DisplayStyle, + skip_quit_confirmation: bool, } impl PersistedUiState { @@ -189,6 +202,7 @@ impl PersistedUiState { workspace_path, no_sessions_confirm_dismissed: false, display_style: DisplayStyle::Classic, + skip_quit_confirmation: false, } } @@ -201,6 +215,7 @@ impl PersistedUiState { workspace_path: state.workspace_path.clone(), no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed, display_style: state.display_style, + skip_quit_confirmation: state.skip_quit_confirmation, } } } @@ -231,6 +246,8 @@ struct StoredGlobalState { orientation: Option, activity_project_limit: Option, display_style: Option, + #[serde(default)] + skip_quit_confirmation: bool, last_workspace_path: Option, updated_at: i64, } @@ -479,8 +496,13 @@ async fn run_inner( workspace_path: restored_ui_state.workspace_path.clone(), no_sessions_confirm_open: false, no_sessions_confirm_dismissed: restored_ui_state.no_sessions_confirm_dismissed, + quit_confirm_open: false, + quit_dont_ask_again: false, + skip_quit_confirmation: restored_ui_state.skip_quit_confirmation, + quit_preference_prompt: None, display_style: restored_ui_state.display_style, system_locale: config.system_locale.clone(), + mouse_position: None, ui_hit_targets: Vec::new(), usage: None, usage_updated_at: None, @@ -603,17 +625,115 @@ fn handle_input_event( usage_refresh_tx: &mpsc::Sender<()>, limits_refresh_tx: &mpsc::Sender<()>, ) -> Result { + if let Some(desired_skip_confirmation) = state.quit_preference_prompt { + return match event { + Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + match ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) { + Some(UiClickAction::ConfirmQuitConfirmationPreference) => { + state.skip_quit_confirmation = desired_skip_confirmation; + state.quit_preference_prompt = None; + Ok(InputOutcome::Continue(true)) + } + Some(UiClickAction::CancelQuitConfirmationPreference) => { + state.quit_preference_prompt = None; + Ok(InputOutcome::Continue(true)) + } + _ => Ok(InputOutcome::Continue(false)), + } + } + Event::Key(key) if key.kind == KeyEventKind::Press => match (key.code, key.modifiers) { + (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => { + state.skip_quit_confirmation = desired_skip_confirmation; + state.quit_preference_prompt = None; + Ok(InputOutcome::Continue(true)) + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Ok(InputOutcome::Quit), + (KeyCode::Enter, _) + | (KeyCode::Esc, _) + | (KeyCode::Char('n'), _) + | (KeyCode::Char('N'), _) + | (KeyCode::Char('q'), _) + | (KeyCode::Char('Q'), _) => { + state.quit_preference_prompt = None; + Ok(InputOutcome::Continue(true)) + } + _ => Ok(InputOutcome::Continue(false)), + }, + Event::Resize(_, _) => Ok(InputOutcome::Continue(true)), + _ => Ok(InputOutcome::Continue(false)), + }; + } + + if state.quit_confirm_open { + return match event { + Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + match ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) { + Some(UiClickAction::ConfirmQuit) => { + state.skip_quit_confirmation = state.quit_dont_ask_again; + Ok(InputOutcome::Quit) + } + Some(UiClickAction::CancelQuit) => { + state.quit_confirm_open = false; + state.quit_dont_ask_again = false; + Ok(InputOutcome::Continue(true)) + } + Some(UiClickAction::ToggleQuitDontAskAgain) => { + state.quit_dont_ask_again = !state.quit_dont_ask_again; + Ok(InputOutcome::Continue(true)) + } + _ => Ok(InputOutcome::Continue(false)), + } + } + Event::Key(key) if key.kind == KeyEventKind::Press => match (key.code, key.modifiers) { + (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => { + state.skip_quit_confirmation = state.quit_dont_ask_again; + Ok(InputOutcome::Quit) + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Ok(InputOutcome::Quit), + (KeyCode::Char(' '), _) => { + state.quit_dont_ask_again = !state.quit_dont_ask_again; + Ok(InputOutcome::Continue(true)) + } + (KeyCode::Enter, _) + | (KeyCode::Esc, _) + | (KeyCode::Char('n'), _) + | (KeyCode::Char('N'), _) + | (KeyCode::Char('q'), _) + | (KeyCode::Char('Q'), _) => { + state.quit_confirm_open = false; + state.quit_dont_ask_again = false; + Ok(InputOutcome::Continue(true)) + } + _ => Ok(InputOutcome::Continue(false)), + }, + Event::Resize(_, _) => Ok(InputOutcome::Continue(true)), + _ => Ok(InputOutcome::Continue(false)), + }; + } + if let Event::Mouse(mouse) = &event { + let mouse_position = Some((mouse.column, mouse.row)); + let mouse_moved = state.mouse_position != mouse_position; + state.mouse_position = mouse_position; + if state.show_help || state.no_sessions_confirm_open { return Ok(InputOutcome::Continue(false)); } if mouse.kind == MouseEventKind::Down(MouseButton::Left) { if let Some(action) = ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) { + if action == UiClickAction::ConfirmQuit + || (action == UiClickAction::PromptQuit && state.skip_quit_confirmation) + { + return Ok(InputOutcome::Quit); + } let changed = apply_ui_click_action(state, action); return Ok(InputOutcome::Continue(changed)); } } + if mouse.kind == MouseEventKind::Moved { + return Ok(InputOutcome::Continue(mouse_moved)); + } } if let Event::Key(key) = &event { @@ -622,7 +742,14 @@ fn handle_input_event( } match (key.code, key.modifiers) { - (KeyCode::Char('q'), _) => return Ok(InputOutcome::Quit), + (KeyCode::Char('q'), _) | (KeyCode::Char('Q'), _) => { + if state.skip_quit_confirmation { + return Ok(InputOutcome::Quit); + } + state.quit_confirm_open = true; + state.quit_dont_ask_again = false; + return Ok(InputOutcome::Continue(true)); + } (KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(InputOutcome::Quit), (KeyCode::Char('s'), _) | (KeyCode::Char('S'), _) | (KeyCode::F(2), _) => { state.active_screen = next_active_screen(state.active_screen); @@ -646,6 +773,7 @@ fn handle_input_event( } if matches!(event, Event::Resize(_, _)) { + state.mouse_position = None; return Ok(InputOutcome::Continue(true)); } @@ -693,6 +821,11 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { state.active_screen = screen; changed } + UiClickAction::SetDisplayStyle(style) => { + let changed = state.display_style != style; + state.display_style = style; + changed + } UiClickAction::SetMetric(metric) => { let changed = state.metric != metric; state.metric = metric; @@ -726,8 +859,36 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { state.activity_project_limit = next; changed } - UiClickAction::CycleDisplayStyle => { - state.display_style = state.display_style.toggled(); + UiClickAction::PromptQuit => { + state.quit_confirm_open = true; + state.quit_dont_ask_again = false; + true + } + UiClickAction::CancelQuit => { + state.quit_confirm_open = false; + state.quit_dont_ask_again = false; + true + } + UiClickAction::ConfirmQuit => { + state.skip_quit_confirmation = state.quit_dont_ask_again; + false + } + UiClickAction::ToggleQuitDontAskAgain => { + state.quit_dont_ask_again = !state.quit_dont_ask_again; + true + } + UiClickAction::PromptQuitConfirmationPreference => { + state.quit_preference_prompt = Some(!state.skip_quit_confirmation); + true + } + UiClickAction::ConfirmQuitConfirmationPreference => { + if let Some(desired_skip_confirmation) = state.quit_preference_prompt.take() { + state.skip_quit_confirmation = desired_skip_confirmation; + } + true + } + UiClickAction::CancelQuitConfirmationPreference => { + state.quit_preference_prompt = None; true } } @@ -997,6 +1158,7 @@ fn load_persisted_ui_state( state.display_style = style; } } + state.skip_quit_confirmation = store.global.skip_quit_confirmation; if let Some(workspace_path) = state.workspace_path.as_ref() { let workspace_key = workspace_path.to_string_lossy(); @@ -1025,6 +1187,7 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul .clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT), ); store.global.display_style = Some(state.display_style.store_value().to_string()); + store.global.skip_quit_confirmation = state.skip_quit_confirmation; store.global.last_workspace_path = workspace_path_text.clone(); store.global.updated_at = now; @@ -1259,6 +1422,19 @@ mod tests { let _ = std::fs::remove_dir_all(comon_home); } + #[test] + fn quit_confirmation_preference_round_trips_through_state_store() { + let comon_home = make_temp_dir("quit-confirmation-preference"); + let mut state = PersistedUiState::default_for_workspace(None); + state.skip_quit_confirmation = true; + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert!(loaded.skip_quit_confirmation); + + let _ = std::fs::remove_dir_all(comon_home); + } + #[test] fn legacy_system_style_restores_as_system_compact() { let comon_home = make_temp_dir("legacy-system-display-style"); diff --git a/src/locale.rs b/src/locale.rs index 6e3d1ec..60c8de3 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -109,7 +109,9 @@ impl<'a> DisplayFormatter<'a> { DisplayStyle::SystemCompact => { format!("System Compact ({})", self.system.id()) } - DisplayStyle::SystemFull => format!("System Full ({})", self.system.id()), + DisplayStyle::SystemFull => { + format!("System Full ({}, space grouped)", self.system.id()) + } } } @@ -124,9 +126,8 @@ impl<'a> DisplayFormatter<'a> { pub(crate) fn format_u64(self, value: u64) -> String { let separator = match self.style { DisplayStyle::Classic => ",", - DisplayStyle::SystemCompact | DisplayStyle::SystemFull => { - self.system.thousands_separator.as_str() - } + DisplayStyle::SystemCompact => self.system.thousands_separator.as_str(), + DisplayStyle::SystemFull => " ", }; group_decimal_digits(value, separator) } @@ -671,6 +672,19 @@ mod tests { assert_eq!(formatter.localize_decimal("1.23M"), "1,23M"); } + #[test] + fn system_full_uses_regular_spaces_for_digit_grouping() { + let system = european_profile(); + let formatter = DisplayFormatter::new(DisplayStyle::SystemFull, &system); + + assert_eq!(formatter.format_count(1_234_567), "1 234 567"); + assert_eq!(formatter.localize_decimal("1.23"), "1,23"); + assert_eq!( + formatter.style_label(), + "System Full (et-EE, space grouped)" + ); + } + #[test] fn system_formats_dates_and_time_from_profile() { let system = european_profile(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4cc48b7..2bc30f9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,7 +5,7 @@ use crate::usage::{ format_tokens_overview, ChartRange, ProjectActivity, UsageMetric, ACTIVITY_TIMELINE_WEEKS, }; use anyhow::Result; -use chrono::{Datelike, Local, NaiveDate, TimeZone, Weekday}; +use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, TimeZone, Weekday}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -78,6 +78,9 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { let area = frame.area(); let (navigation, navigation_targets) = navigation_title(area, state.active_screen); state.ui_hit_targets.extend(navigation_targets); + let (quit, quit_targets) = + quit_title(area, state.quit_confirm_open, state.skip_quit_confirmation); + state.ui_hit_targets.extend(quit_targets); let outer = Block::default() .borders(Borders::ALL) @@ -86,7 +89,8 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { format!(" comon :: {} ", env!("CARGO_PKG_VERSION")), Style::default().add_modifier(Modifier::BOLD), )) - .title_top(navigation.right_aligned()); + .title_top(navigation.right_aligned()) + .title_bottom(quit.right_aligned()); frame.render_widget(outer, area); let inner = apply_margin( @@ -152,20 +156,17 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { } } ActiveScreen::Read => { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(2), - ]) - .split(inner); - register_hit_target(state, chunks[2], UiClickAction::CycleDisplayStyle); let system_locale = state.system_locale.clone(); let formatter = DisplayFormatter::new(state.display_style, &system_locale); crate::read::tui::render(frame, inner, &mut state.read_browser, formatter); } } + + if state.quit_confirm_open { + render_quit_confirmation(frame, area, state); + } else if state.quit_preference_prompt.is_some() { + render_quit_preference_confirmation(frame, area, state); + } } fn usage_screen_layout(area: Rect, footer_height: u16) -> [Rect; 3] { @@ -230,6 +231,34 @@ fn navigation_title(area: Rect, active_screen: ActiveScreen) -> (Line<'static>, (line, right_aligned_targets(title_area, &segments)) } +fn quit_title( + area: Rect, + active: bool, + skip_confirmation: bool, +) -> (Line<'static>, Vec) { + let title_area = Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(area.height.saturating_sub(1)), + area.width.saturating_sub(2), + 1, + ); + let checkbox = if skip_confirmation { " [x] " } else { " [ ] " }; + let segments = [ + ( + checkbox, + Some(UiClickAction::PromptQuitConfirmationPreference), + ), + (" QUIT ", Some(UiClickAction::PromptQuit)), + (" ", None), + ]; + let line = Line::from(vec![ + checkbox_span(skip_confirmation, None), + pill("QUIT", active), + Span::raw(" "), + ]); + (line, right_aligned_targets(title_area, &segments)) +} + fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let line_area = usage_header_line_area(area); let row = Layout::default() @@ -347,7 +376,7 @@ fn footer_height(width: u16, state: &AppState) -> u16 { wrapped_line_count(&footer_text(state), width).max(1) } -fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { +fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let err = footer_error(state); let style = format!("Style [n]: {}", state.formatter().style_label()); let line = Line::from(vec![ @@ -362,7 +391,6 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { ]); frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), area); - register_hit_target(state, area, UiClickAction::CycleDisplayStyle); } fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { @@ -922,13 +950,27 @@ fn render_usage_controls( let month = pill("MONTH", state.range == ChartRange::Month); let vert = pill("VERT", state.orientation == ChartOrientation::Vertical); let horz = pill("HORZ", state.orientation == ChartOrientation::Horizontal); + let classic = pill("CLASS", state.display_style == DisplayStyle::Classic); + let system_compact = pill("SCOMP", state.display_style == DisplayStyle::SystemCompact); + let system_full = pill("SFULL", state.display_style == DisplayStyle::SystemFull); render_flat_usage_controls( frame, chunks[0], state, &workspace_label, - vec![tokens, time, runs, week, month, vert, horz], + vec![ + tokens, + time, + runs, + week, + month, + vert, + horz, + classic, + system_compact, + system_full, + ], ); if let Some(text) = reset_summary { @@ -957,7 +999,7 @@ fn render_flat_usage_controls( ) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Min(0)]) + .constraints([Constraint::Min(20), Constraint::Length(92)]) .split(area); render_workspace_label(frame, row[0], workspace_label); @@ -984,6 +1026,19 @@ fn render_flat_usage_controls( " HORZ ", Some(UiClickAction::SetOrientation(ChartOrientation::Horizontal)), ), + (" STYLE ", None), + ( + " CLASS ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), + ), + ( + " SCOMP ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), + ), + ( + " SFULL ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), + ), ], ); @@ -993,6 +1048,8 @@ fn render_flat_usage_controls( spans.extend(pills[3..5].iter().cloned()); spans.push(control_group_label("BARS")); spans.extend(pills[5..7].iter().cloned()); + spans.push(control_group_label("STYLE")); + spans.extend(pills[7..10].iter().cloned()); frame.render_widget( Paragraph::new(Line::from(spans)).alignment(Alignment::Right), row[1], @@ -1325,6 +1382,7 @@ fn usage_cards_height(state: &AppState, width: u16) -> u16 { fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let formatter = state.formatter(); + let today_title = today_card_title(formatter, Local::now().naive_local()); let card_layout = usage_card_layout(area.width); let row_heights = usage_card_row_heights(state, area.width); let two_rows = row_heights.len() > 1; @@ -1457,7 +1515,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1511,7 +1569,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let runs7 = last7_runs_caption.as_deref(); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1611,7 +1669,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1662,7 +1720,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let runs7 = last7_runs_caption.as_deref(); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1789,7 +1847,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1837,7 +1895,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ); frame.render_widget( card( - "TODAY", + &today_title, &today_value, Some(&today_caption1), Some(&today_caption2), @@ -1982,6 +2040,27 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let bw = bar_width.max(1); let max_value = values.iter().copied().max().unwrap_or(0).max(1); + let hovered_bar = hovered_vertical_bar_index( + state.mouse_position, + bars_area, + bw, + bar_gap, + &values, + max_value, + ); + let hover_tooltip = hovered_bar.and_then(|index| { + state.mouse_position.map(|mouse| { + ( + mouse, + format_vertical_bar_tooltip( + &days[index].day, + values[index], + state.metric, + formatter, + ), + ) + }) + }); let buf = frame.buffer_mut(); // Bars are laid left-to-right. @@ -2045,7 +2124,7 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } } UsageMetric::Runs => { - let raw = value.to_string(); + let raw = formatter.format_u64(*value); if raw.len() <= w as usize { raw } else { @@ -2084,6 +2163,10 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } } } + + if let Some((mouse, tooltip)) = hover_tooltip { + render_chart_tooltip(frame, inner, mouse, &tooltip); + } } ChartOrientation::Horizontal => { // Multi-line horizontal bars, values outside to the right. @@ -2286,6 +2369,126 @@ fn usage_chart_metric_label(metric: UsageMetric) -> &'static str { } } +fn hovered_vertical_bar_index( + mouse_position: Option<(u16, u16)>, + bars_area: Rect, + bar_width: u16, + bar_gap: u16, + values: &[u64], + max_value: u64, +) -> Option { + let (mouse_x, mouse_y) = mouse_position?; + let right = bars_area.x.saturating_add(bars_area.width); + let bottom = bars_area.y.saturating_add(bars_area.height); + if mouse_x < bars_area.x + || mouse_x >= right + || mouse_y < bars_area.y + || mouse_y >= bottom + || bar_width == 0 + { + return None; + } + + let stride = bar_width.saturating_add(bar_gap); + if stride == 0 { + return None; + } + let offset_x = mouse_x.saturating_sub(bars_area.x); + let index = (offset_x / stride) as usize; + let value = *values.get(index)?; + let bar_x = bars_area + .x + .saturating_add((index as u16).saturating_mul(stride)); + let actual_width = bar_width.min(right.saturating_sub(bar_x)); + if mouse_x >= bar_x.saturating_add(actual_width) { + return None; + } + + let ratio = (value as f64) / (max_value.max(1) as f64); + let filled_height = ((bars_area.height as f64) * ratio.clamp(0.0, 1.0)).round() as u16; + if filled_height == 0 { + return None; + } + let bottom_y = bottom.saturating_sub(1); + let top_filled_y = bottom_y.saturating_sub(filled_height.saturating_sub(1)); + (mouse_y >= top_filled_y).then_some(index) +} + +fn format_vertical_bar_tooltip( + day: &str, + value: u64, + metric: UsageMetric, + formatter: DisplayFormatter<'_>, +) -> String { + let date = NaiveDate::parse_from_str(day, "%Y-%m-%d") + .map(|date| formatter.format_full_date(date)) + .unwrap_or_else(|_| day.to_string()); + let value = match metric { + UsageMetric::Tokens => format!("{} tokens", formatter.format_u64(value)), + UsageMetric::Time => format_duration_words( + value.min((i64::MAX as u64) / 60_000).saturating_mul(60_000) as i64, + ), + UsageMetric::Runs => format!("{} runs", formatter.format_u64(value)), + }; + format!("{date} | {value}") +} + +fn render_chart_tooltip(frame: &mut Frame<'_>, bounds: Rect, mouse: (u16, u16), text: &str) { + let Some(area) = chart_tooltip_rect( + bounds, + mouse, + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16, + ) else { + return; + }; + let text = truncate_middle(text, area.width.saturating_sub(2) as usize); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::White)) + .style(Style::default().bg(Color::Black)); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + text, + Style::default().add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Center) + .block(block), + area, + ); +} + +fn chart_tooltip_rect(bounds: Rect, mouse: (u16, u16), content_width: u16) -> Option { + if bounds.width < 3 || bounds.height < 3 { + return None; + } + let width = content_width.saturating_add(2).clamp(3, bounds.width); + let height = 3; + let right = bounds.x.saturating_add(bounds.width); + let bottom = bounds.y.saturating_add(bounds.height); + let max_x = right.saturating_sub(width); + let max_y = bottom.saturating_sub(height); + + let preferred_x = if mouse.0.saturating_add(1).saturating_add(width) <= right { + mouse.0.saturating_add(1) + } else { + mouse.0.saturating_sub(width) + }; + let preferred_y = if mouse.1 >= bounds.y.saturating_add(height) { + mouse.1.saturating_sub(height) + } else { + mouse.1.saturating_add(1) + }; + + Some(Rect::new( + preferred_x.clamp(bounds.x, max_x), + preferred_y.clamp(bounds.y, max_y), + width, + height, + )) +} + fn format_vertical_token_value( value: u64, max_width: u16, @@ -2296,7 +2499,7 @@ fn format_vertical_token_value( DisplayStyle::SystemCompact => format_tokens_compact(value as i64, formatter), DisplayStyle::SystemFull => formatter.format_u64(value), }; - if preferred.len() <= max_width as usize || formatter.style() == DisplayStyle::SystemFull { + if preferred.len() <= max_width as usize { preferred } else { format_compact_kmb(value, max_width, formatter) @@ -2455,11 +2658,11 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" w - toggle timeframe (Week/Month)"), Line::from(" f - toggle layout (Horz/Vert)"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click tabs/controls; footer cycles style"), + Line::from(" Mouse - click tabs/controls/format/quit"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), - Line::from(" q/Esc - quit"), + Line::from(" q - quit (confirm)"), ]), ActiveScreen::Activity => Text::from(vec![ Line::from("Keys:"), @@ -2467,26 +2670,26 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" +/= - show more projects"), Line::from(" - - show fewer projects"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click tabs/view/projects; footer cycles style"), + Line::from(" Mouse - click tabs/view/projects/quit"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), - Line::from(" q/Esc - quit"), + Line::from(" q - quit (confirm)"), ]), ActiveScreen::LimitResets => Text::from(vec![ Line::from("Keys:"), Line::from(" r/F5 - refresh reset credits"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click tabs; footer cycles style"), + Line::from(" Mouse - click tabs/quit"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), - Line::from(" q/Esc - quit"), + Line::from(" q - quit (confirm)"), ]), ActiveScreen::Read => Text::from(vec![ Line::from("Keys:"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click tabs; footer cycles style"), - Line::from(" q/Esc - quit"), + Line::from(" Mouse - click tabs/quit"), + Line::from(" q - quit (confirm)"), ]), }; frame.render_widget( @@ -2551,6 +2754,185 @@ fn render_no_sessions_overlay(frame: &mut Frame<'_>, area: Rect, state: &AppStat ); } +fn render_quit_confirmation(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + state.ui_hit_targets.clear(); + + let popup = centered_rect(area.width.min(39), area.height.min(10), area); + frame.render_widget(Clear, popup); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title(Span::styled( + " Quit ", + Style::default().add_modifier(Modifier::BOLD), + )), + popup, + ); + + let message_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(2), + popup.width.saturating_sub(2), + 3.min(popup.height.saturating_sub(3)), + ); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from("Are you sure you want to quit?"), + Line::from(Span::styled( + "Y confirms; N/Esc/Enter cancels", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "Space toggles the checkbox", + Style::default().fg(Color::Gray), + )), + ])) + .alignment(Alignment::Center), + message_area, + ); + + let checkbox_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(6), + popup.width.saturating_sub(2), + 1, + ); + let checkbox_text = if state.quit_dont_ask_again { + "[x] Don't show again" + } else { + "[ ] Don't show again" + }; + state.ui_hit_targets.extend(centered_targets( + checkbox_area, + &[(checkbox_text, Some(UiClickAction::ToggleQuitDontAskAgain))], + )); + frame.render_widget( + Paragraph::new(Line::from(checkbox_span( + state.quit_dont_ask_again, + Some("Don't show again"), + ))) + .alignment(Alignment::Center), + checkbox_area, + ); + + let buttons_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(popup.height.saturating_sub(2)), + popup.width.saturating_sub(2), + 1, + ); + let segments = [ + (" YES ", Some(UiClickAction::ConfirmQuit)), + (" ", None), + (" NO ", Some(UiClickAction::CancelQuit)), + ]; + state + .ui_hit_targets + .extend(centered_targets(buttons_area, &segments)); + frame.render_widget( + Paragraph::new(Line::from(vec![ + pill("YES", false), + Span::raw(" "), + pill("NO", true), + ])) + .alignment(Alignment::Center), + buttons_area, + ); +} + +fn render_quit_preference_confirmation(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + state.ui_hit_targets.clear(); + let disable_confirmation = state.quit_preference_prompt.unwrap_or(false); + let (title, question, explanation) = if disable_confirmation { + ( + " Exit confirmation ", + "Disable exit confirmation?", + "q and QUIT will exit immediately.", + ) + } else { + ( + " Exit confirmation ", + "Enable exit confirmation?", + "q and QUIT will ask before exiting.", + ) + }; + + let popup = centered_rect(area.width.min(43), area.height.min(8), area); + frame.render_widget(Clear, popup); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )), + popup, + ); + + let message_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(2), + popup.width.saturating_sub(2), + 2.min(popup.height.saturating_sub(3)), + ); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from(question), + Line::from(Span::styled(explanation, Style::default().fg(Color::Gray))), + ])) + .alignment(Alignment::Center), + message_area, + ); + + let buttons_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(popup.height.saturating_sub(2)), + popup.width.saturating_sub(2), + 1, + ); + let segments = [ + ( + " YES ", + Some(UiClickAction::ConfirmQuitConfirmationPreference), + ), + (" ", None), + ( + " NO ", + Some(UiClickAction::CancelQuitConfirmationPreference), + ), + ]; + state + .ui_hit_targets + .extend(centered_targets(buttons_area, &segments)); + frame.render_widget( + Paragraph::new(Line::from(vec![ + pill("YES", false), + Span::raw(" "), + pill("NO", true), + ])) + .alignment(Alignment::Center), + buttons_area, + ); +} + +fn checkbox_span(checked: bool, label: Option<&str>) -> Span<'static> { + let mark = if checked { "x" } else { " " }; + let text = match label { + Some(label) => format!("[{mark}] {label}"), + None => format!(" [{mark}] "), + }; + let style = if checked { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + Span::styled(text, style) +} + fn pill(label: &str, active: bool) -> Span<'static> { let style = if active { Style::default() @@ -2563,12 +2945,6 @@ fn pill(label: &str, active: bool) -> Span<'static> { Span::styled(format!(" {label} "), style) } -fn register_hit_target(state: &mut AppState, area: Rect, action: UiClickAction) { - if area.width > 0 && area.height > 0 { - state.ui_hit_targets.push(UiHitTarget { area, action }); - } -} - fn register_right_aligned_targets( state: &mut AppState, area: Rect, @@ -2612,6 +2988,33 @@ fn right_aligned_targets( targets } +fn centered_targets(area: Rect, segments: &[(&str, Option)]) -> Vec { + let total_width = segments.iter().fold(0_u16, |total, (text, _)| { + total.saturating_add(UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16) + }); + if total_width > area.width || area.height == 0 { + return Vec::new(); + } + + let mut targets = Vec::new(); + let mut x = area + .x + .saturating_add((area.width.saturating_sub(total_width)) / 2); + for (text, action) in segments { + let width = UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16; + if let Some(action) = action { + if width > 0 { + targets.push(UiHitTarget { + area: Rect::new(x, area.y, width, 1), + action: *action, + }); + } + } + x = x.saturating_add(width); + } + targets +} + fn card( title: &str, value: &str, @@ -2621,6 +3024,13 @@ fn card( card_with_captions(title, value, &[caption1, caption2]) } +fn today_card_title(formatter: DisplayFormatter<'_>, now: NaiveDateTime) -> String { + format!( + "TODAY_{}", + formatter.format_session_datetime(now).replace(' ', "_") + ) +} + fn card4( title: &str, value: &str, @@ -3371,6 +3781,23 @@ mod tests { assert_eq!(targets[3].area, Rect::new(54, 2, 9, 1)); } + #[test] + fn today_card_title_includes_the_current_date_and_time() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(DisplayStyle::Classic, &system_locale); + let now = NaiveDate::from_ymd_opt(2026, 7, 22) + .unwrap() + .and_hms_opt(14, 35, 0) + .unwrap(); + + assert_eq!(today_card_title(formatter, now), "TODAY_2026-07-22_14:35"); + + for style in [DisplayStyle::SystemCompact, DisplayStyle::SystemFull] { + let formatter = DisplayFormatter::new(style, &system_locale); + assert_eq!(today_card_title(formatter, now), "TODAY_07/22/2026_14:35"); + } + } + #[test] fn navigation_title_hides_tabs_before_they_touch_the_version() { let (title, targets) = navigation_title(Rect::new(0, 0, 52, 10), ActiveScreen::Usage); @@ -3378,6 +3805,39 @@ mod tests { assert!(targets.is_empty()); } + #[test] + fn quit_action_is_on_the_bottom_right_border() { + let (title, targets) = quit_title(Rect::new(4, 2, 71, 20), true, true); + + assert_eq!(title.spans[0].content.as_ref(), " [x] "); + assert_eq!(title.spans[1].style.fg, Some(Color::Black)); + assert_eq!(title.spans[1].style.bg, Some(Color::White)); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].area, Rect::new(62, 21, 5, 1)); + assert_eq!( + targets[0].action, + UiClickAction::PromptQuitConfirmationPreference + ); + assert_eq!(targets[1].area, Rect::new(67, 21, 6, 1)); + assert_eq!(targets[1].action, UiClickAction::PromptQuit); + } + + #[test] + fn quit_confirmation_buttons_are_centered_and_separate() { + let targets = centered_targets( + Rect::new(10, 5, 33, 1), + &[ + (" YES ", Some(UiClickAction::ConfirmQuit)), + (" ", None), + (" NO ", Some(UiClickAction::CancelQuit)), + ], + ); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].area, Rect::new(20, 5, 5, 1)); + assert_eq!(targets[1].area, Rect::new(28, 5, 4, 1)); + } + #[test] fn usage_header_has_one_blank_row_above_and_below() { let chunks = usage_screen_layout(Rect::new(2, 1, 100, 20), 1); @@ -3673,7 +4133,7 @@ mod tests { } #[test] - fn system_full_keeps_chart_token_values_expanded() { + fn system_full_compacts_only_tight_vertical_token_values() { let system_locale = crate::locale::SystemLocale::default(); let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::SystemFull, &system_locale); @@ -3686,11 +4146,11 @@ mod tests { u16::MAX, formatter, ), - "45456785 / 1756241" + "45 456 785 / 1 756 241" ); assert_eq!( format_vertical_token_value(45_456_785, 20, formatter), - "45456785" + "45 456 785" ); assert_eq!( format_horizontal_value( @@ -3700,11 +4160,46 @@ mod tests { 8, formatter, ), - "45456785 / 1756241" + "45 456 785 / 1 756 241" ); + assert_eq!(format_vertical_token_value(45_456_785, 4, formatter), "45M"); + } + + #[test] + fn vertical_bar_hover_requires_the_filled_bar_and_ignores_gaps() { + let area = Rect::new(10, 5, 11, 10); + let values = [50, 100]; + assert_eq!( - format_vertical_token_value(45_456_785, 4, formatter), - "45456785" + hovered_vertical_bar_index(Some((10, 10)), area, 3, 1, &values, 100), + Some(0) + ); + assert_eq!( + hovered_vertical_bar_index(Some((10, 9)), area, 3, 1, &values, 100), + None + ); + assert_eq!( + hovered_vertical_bar_index(Some((13, 12)), area, 3, 1, &values, 100), + None + ); + assert_eq!( + hovered_vertical_bar_index(Some((14, 5)), area, 3, 1, &values, 100), + Some(1) + ); + } + + #[test] + fn vertical_bar_tooltip_shows_exact_value_and_stays_inside_chart() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(DisplayStyle::Classic, &system_locale); + + assert_eq!( + format_vertical_bar_tooltip("2026-07-22", 45_456_785, UsageMetric::Tokens, formatter,), + "2026-07-22 | 45,456,785 tokens" + ); + assert_eq!( + chart_tooltip_rect(Rect::new(2, 2, 20, 10), (20, 3), 10), + Some(Rect::new(8, 4, 12, 3)) ); } diff --git a/src/usage/mod.rs b/src/usage/mod.rs index 925ce22..6dc724c 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -18,7 +18,7 @@ const DEFAULT_MAX_SESSION_FILES_SCANNED: usize = 10_000; const DEFAULT_MAX_JSONL_LINE_BYTES: usize = 512 * 1024; const DEFAULT_SCAN_TIME_BUDGET_MS: u64 = 1500; const MAX_DISTINCT_MODELS: usize = 5_000; -const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 3; +const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 4; const FORK_REPLAY_END_GAP_MS: i64 = 1_000; const FORK_REPLAY_NO_TOKEN_GRACE_MS: i64 = 2_000; pub const DEFAULT_SCAN_CACHE_MAX_ENTRIES: usize = 50_000; @@ -1206,12 +1206,15 @@ fn parse_file_summary( session_cwd = extract_cwd(&value); } - if entry_type == "session_meta" { - maybe_start_fork_replay(&value, &mut first_session_meta_seen, &mut fork_replay); - } + let started_fork_replay = if entry_type == "session_meta" { + maybe_start_fork_replay(&value, &mut first_session_meta_seen, &mut fork_replay) + } else { + false + }; let event_timestamp_ms = read_timestamp_ms(&value); - let skip_fork_replay = fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms); + let skip_fork_replay = started_fork_replay + || fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms); if entry_type == "turn_context" { if !skip_fork_replay { @@ -1491,8 +1494,10 @@ fn open_or_init_scan_cache_db(path: &Path) -> Result { if schema_version == 1 || !has_v2_columns { migrate_scan_cache_db_v1_to_v2(&conn, path)?; } + if schema_version < 4 { + invalidate_forked_session_cache_rows(&conn, path)?; + } if schema_version < SCAN_CACHE_DB_SCHEMA_VERSION { - migrate_scan_cache_db_to_v3(&conn, path)?; conn.execute( "UPDATE cache_meta SET value = ?1 WHERE key = 'schema_version';", params![SCAN_CACHE_DB_SCHEMA_VERSION], @@ -1576,9 +1581,10 @@ fn migrate_scan_cache_db_v1_to_v2(conn: &Connection, path: &Path) -> Result<()> Ok(()) } -fn migrate_scan_cache_db_to_v3(conn: &Connection, path: &Path) -> Result<()> { - // v3 changes forked-session accounting semantics. Reusing those old rows would keep - // inflated totals, but non-forked cache rows are still valid. +fn invalidate_forked_session_cache_rows(conn: &Connection, path: &Path) -> Result<()> { + // v3 and v4 change forked-session accounting semantics. Reusing older fork rows + // would keep inflated totals, but non-forked cache rows are still valid. One pass + // is sufficient even when upgrading across multiple schema versions at once. let mut stmt = conn .prepare("SELECT file_path FROM file_cache;") .with_context(|| { @@ -1603,7 +1609,7 @@ fn migrate_scan_cache_db_to_v3(conn: &Connection, path: &Path) -> Result<()> { path.display() ) })?; - if cached_session_needs_v3_reparse(&file_path) { + if forked_session_cache_needs_reparse(&file_path) { stale_paths.push(file_path); } } @@ -1625,7 +1631,7 @@ fn migrate_scan_cache_db_to_v3(conn: &Connection, path: &Path) -> Result<()> { Ok(()) } -fn cached_session_needs_v3_reparse(file_path: &str) -> bool { +fn forked_session_cache_needs_reparse(file_path: &str) -> bool { let file = match File::open(file_path) { Ok(file) => file, Err(_) => return true, @@ -2180,28 +2186,29 @@ fn maybe_start_fork_replay( value: &Value, first_session_meta_seen: &mut bool, replay: &mut ForkReplayState, -) { +) -> bool { if *first_session_meta_seen { - return; + return false; } *first_session_meta_seen = true; let payload = value.get("payload").and_then(Value::as_object); let Some(payload) = payload else { - return; + return false; }; if payload .get("forked_from_id") .and_then(Value::as_str) .is_none() { - return; + return false; } - let start_ms = payload - .get("timestamp") - .and_then(parse_timestamp_value_ms) - .or_else(|| read_timestamp_ms(value)); + // The outer timestamp records when this JSONL event was emitted. The payload + // timestamp can be earlier because preparing a large fork replay takes time; + // using it as last_event_ms can falsely look like the end-of-replay gap. + let start_ms = read_timestamp_ms(value) + .or_else(|| payload.get("timestamp").and_then(parse_timestamp_value_ms)); *replay = ForkReplayState { active: true, done: false, @@ -2209,6 +2216,7 @@ fn maybe_start_fork_replay( last_event_ms: start_ms, token_events: 0, }; + true } fn fork_replay_should_skip_event( @@ -2224,21 +2232,24 @@ fn fork_replay_should_skip_event( }; let start_ms = replay.start_ms.unwrap_or(timestamp_ms); let elapsed_ms = timestamp_ms - start_ms; - let gap_ms = replay - .last_event_ms + let previous_event_ms = replay.last_event_ms; + let gap_ms = previous_event_ms .map(|last_ms| timestamp_ms - last_ms) .unwrap_or(0); + let monotonic_event_ms = previous_event_ms + .map(|last_ms| last_ms.max(timestamp_ms)) + .unwrap_or(timestamp_ms); if gap_ms >= FORK_REPLAY_END_GAP_MS || (replay.token_events == 0 && elapsed_ms >= FORK_REPLAY_NO_TOKEN_GRACE_MS) { replay.active = false; replay.done = true; - replay.last_event_ms = Some(timestamp_ms); + replay.last_event_ms = Some(monotonic_event_ms); return false; } - replay.last_event_ms = Some(timestamp_ms); + replay.last_event_ms = Some(monotonic_event_ms); true } @@ -2649,6 +2660,64 @@ mod tests { append_agent_message_line(path, timestamp_ms + 1_800); } + fn write_delayed_fork_replay_prefix( + path: &Path, + payload_timestamp_ms: i64, + outer_delay_ms: i64, + ) -> i64 { + let payload_timestamp = Utc + .timestamp_millis_opt(payload_timestamp_ms) + .single() + .expect("valid payload timestamp") + .to_rfc3339(); + let outer_timestamp_ms = payload_timestamp_ms + outer_delay_ms; + let outer_timestamp = Utc + .timestamp_millis_opt(outer_timestamp_ms) + .single() + .expect("valid outer timestamp") + .to_rfc3339(); + append_json_line( + path, + serde_json::json!({ + "type": "session_meta", + "timestamp": outer_timestamp, + "payload": { + "id": "fork-child", + "forked_from_id": "parent", + "timestamp": payload_timestamp, + "cwd": "/tmp/forked-project" + } + }), + ); + append_json_line( + path, + serde_json::json!({ + "type": "session_meta", + "timestamp": Utc + .timestamp_millis_opt(outer_timestamp_ms + 1) + .single() + .expect("valid replay metadata timestamp") + .to_rfc3339(), + "payload": { + "id": "parent", + "timestamp": payload_timestamp, + "cwd": "/tmp/forked-project" + } + }), + ); + append_total_token_line(path, outer_timestamp_ms + 2, 1_000, 800, 100); + for offset in 3..67 { + append_agent_message_line(path, outer_timestamp_ms + offset); + } + append_total_token_line(path, outer_timestamp_ms + 100, 1_500, 1_300, 130); + outer_timestamp_ms + } + + fn append_fork_replay_live_tail(path: &Path, outer_timestamp_ms: i64) { + append_total_token_line(path, outer_timestamp_ms + 4_000, 1_700, 1_400, 150); + append_agent_message_line(path, outer_timestamp_ms + 4_001); + } + fn default_test_limits(full_scan: bool) -> ScanLimits { ScanLimits { max_session_file_bytes: 4 * 1024 * 1024, @@ -2687,6 +2756,24 @@ mod tests { assert_eq!(monday.weekday(), Weekday::Mon); } + #[test] + fn fork_replay_keeps_last_event_timestamp_monotonic() { + let mut replay = ForkReplayState { + active: true, + done: false, + start_ms: Some(1_000), + last_event_ms: Some(1_000), + token_events: 1, + }; + + assert!(fork_replay_should_skip_event(&mut replay, Some(1_100))); + assert!(fork_replay_should_skip_event(&mut replay, Some(900))); + assert_eq!(replay.last_event_ms, Some(1_100)); + assert!(fork_replay_should_skip_event(&mut replay, Some(1_150))); + assert!(replay.active); + assert!(!replay.done); + } + #[test] fn compute_snapshot_ignores_forked_session_replay_without_cache() { let root = make_temp_dir("fork-replay-no-cache"); @@ -2754,6 +2841,93 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn compute_snapshot_ignores_delayed_fork_metadata_replay() { + let root = make_temp_dir("fork-replay-delayed-metadata"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + + let payload_timestamp_ms = + Utc::now().timestamp_millis() - Duration::hours(1).num_milliseconds(); + let session_path = sessions_root.join("forked.jsonl"); + let outer_timestamp_ms = + write_delayed_fork_replay_prefix(&session_path, payload_timestamp_ms, 1_500); + append_fork_replay_live_tail(&session_path, outer_timestamp_ms); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + assert_eq!(snapshot.totals.last30_days_tokens, 220); + assert_eq!( + snapshot.days.iter().map(|day| day.agent_runs).sum::(), + 1, + "the delayed outer session timestamp must not expose compressed replay runs" + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn compute_snapshot_resumes_active_delayed_fork_replay_from_cache() { + let root = make_temp_dir("fork-replay-delayed-resume"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + + let payload_timestamp_ms = + Utc::now().timestamp_millis() - Duration::hours(1).num_milliseconds(); + let session_path = sessions_root.join("forked.jsonl"); + let outer_timestamp_ms = + write_delayed_fork_replay_prefix(&session_path, payload_timestamp_ms, 1_500); + let cache_db_path = root.join("comon.db"); + + let replay_only = compute_snapshot( + 30, + &codex_home, + None, + default_test_limits(false), + Some(cache_db_path.as_path()), + ) + .expect("replay-only snapshot"); + assert_eq!(replay_only.totals.last30_days_tokens, 0); + assert_eq!( + replay_only + .days + .iter() + .map(|day| day.agent_runs) + .sum::(), + 0 + ); + + append_fork_replay_live_tail(&session_path, outer_timestamp_ms); + let resumed = compute_snapshot( + 30, + &codex_home, + None, + default_test_limits(false), + Some(cache_db_path.as_path()), + ) + .expect("resumed snapshot"); + assert_eq!(resumed.totals.last30_days_tokens, 220); + assert_eq!( + resumed.days.iter().map(|day| day.agent_runs).sum::(), + 1 + ); + + let cached = compute_snapshot( + 30, + &codex_home, + None, + default_test_limits(false), + Some(cache_db_path.as_path()), + ) + .expect("cached resumed snapshot"); + assert_eq!(cached.totals.last30_days_tokens, 220); + assert_eq!(cached.days.iter().map(|day| day.agent_runs).sum::(), 1); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn compute_snapshot_builds_project_activity_sorted_by_last_activity() { let root = make_temp_dir("project-activity"); @@ -3248,6 +3422,75 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn open_or_init_scan_cache_db_v4_reparses_only_forked_rows() { + let root = make_temp_dir("cache-migrate-v4"); + let sessions_root = root.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let keep_path = sessions_root.join("keep.jsonl"); + let forked_path = sessions_root.join("forked.jsonl"); + let now_ms = Utc::now().timestamp_millis(); + write_token_file(&keep_path, now_ms, 10, 5); + write_delayed_fork_replay_prefix(&forked_path, now_ms, 1_500); + + let db_path = root.join("comon.db"); + let conn = Connection::open(&db_path).expect("open v3 db"); + conn.execute_batch( + " + CREATE TABLE cache_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + INSERT INTO cache_meta(key, value) VALUES('schema_version', 3); + CREATE TABLE file_cache ( + file_path TEXT PRIMARY KEY, + file_size INTEGER NOT NULL, + file_mtime INTEGER, + file_offset INTEGER NOT NULL DEFAULT 0, + fully_parsed INTEGER NOT NULL DEFAULT 1, + session_cwd TEXT, + parser_state_json TEXT NOT NULL DEFAULT '{}', + daily_json TEXT NOT NULL, + model_daily_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + ", + ) + .expect("create v3 schema"); + for path in [&keep_path, &forked_path] { + conn.execute( + " + INSERT INTO file_cache( + file_path, file_size, file_mtime, file_offset, fully_parsed, + session_cwd, parser_state_json, daily_json, model_daily_json, updated_at + ) VALUES(?1, 1, 1, 1, 1, '/tmp', '{}', '{}', '{}', 1); + ", + rusqlite::params![path.to_string_lossy().to_string()], + ) + .expect("insert cache row"); + } + drop(conn); + + let db = open_or_init_scan_cache_db(&db_path).expect("open and migrate"); + let (store, _) = load_scan_cache_store(&db).expect("load migrated cache"); + let keep_key = keep_path.to_string_lossy().to_string(); + let forked_key = forked_path.to_string_lossy().to_string(); + assert!(store.entries.contains_key(&keep_key)); + assert!(!store.entries.contains_key(&forked_key)); + + let schema_version: i64 = db + .conn + .query_row( + "SELECT value FROM cache_meta WHERE key = 'schema_version';", + [], + |row| row.get(0), + ) + .expect("read schema version"); + assert_eq!(schema_version, 4); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn open_or_init_scan_cache_db_migrates_v1_schema_and_clears_stale_rows() { let root = make_temp_dir("cache-migrate");