Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project are documented in this file.

## Unreleased

- 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.
- Grouped the Usage screen's View, Graph, and Bars controls into aligned thin-border panels.
- 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.

## 0.3.6 - 2026-06-05

- Added `--live-limits auto|on|off` so app-only installs can run without a live-limits spawn error.
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
unicode-width = "0.2.2"

[target.'cfg(unix)'.dependencies]
libc = "0.2.180"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Globalization"] }
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512
- `Tab` Toggle data (Tokens/Time/Runs)
- `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
- `s` / `F2` Switch between Usage and Session history
- `r` / `F5` Refresh current screen
- `?` Help overlay
Expand Down Expand Up @@ -340,6 +342,7 @@ 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.
- 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.
Expand Down
179 changes: 178 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use crate::codex_rpc::{AccountRateLimits, CodexRpc};
use crate::locale::{DisplayFormatter, DisplayStyle, SystemLocale};
use crate::read;
use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric};
use anyhow::{anyhow, Context, Result};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
Expand All @@ -28,6 +30,7 @@ pub struct Config {
pub refresh_limits_secs: u64,
pub usage_scan_limits: crate::usage::ScanLimits,
pub rebuild_cache_on_start: bool,
pub(crate) system_locale: SystemLocale,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -68,6 +71,32 @@ pub(crate) enum ActiveScreen {
Read,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UiClickAction {
SetScreen(ActiveScreen),
SetMetric(UsageMetric),
SetRange(ChartRange),
SetOrientation(ChartOrientation),
DecreaseProjects,
IncreaseProjects,
CycleDisplayStyle,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UiHitTarget {
pub(crate) area: Rect,
pub(crate) action: UiClickAction,
}

impl UiHitTarget {
fn contains(self, column: u16, row: u16) -> bool {
column >= self.area.x
&& column < self.area.x.saturating_add(self.area.width)
&& row >= self.area.y
&& row < self.area.y.saturating_add(self.area.height)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputOutcome {
Continue(bool),
Expand Down Expand Up @@ -109,6 +138,9 @@ pub(crate) struct AppState {
pub(crate) workspace_path: Option<std::path::PathBuf>,
pub(crate) no_sessions_confirm_open: bool,
pub(crate) no_sessions_confirm_dismissed: bool,
pub(crate) display_style: DisplayStyle,
pub(crate) system_locale: SystemLocale,
pub(crate) ui_hit_targets: Vec<UiHitTarget>,

pub(crate) usage: Option<LocalUsageSnapshot>,
pub(crate) usage_updated_at: Option<Instant>,
Expand Down Expand Up @@ -144,6 +176,7 @@ struct PersistedUiState {
activity_project_limit: usize,
workspace_path: Option<PathBuf>,
no_sessions_confirm_dismissed: bool,
display_style: DisplayStyle,
}

impl PersistedUiState {
Expand All @@ -155,6 +188,7 @@ impl PersistedUiState {
activity_project_limit: DEFAULT_ACTIVITY_PROJECT_LIMIT,
workspace_path,
no_sessions_confirm_dismissed: false,
display_style: DisplayStyle::Classic,
}
}

Expand All @@ -166,6 +200,7 @@ impl PersistedUiState {
activity_project_limit: state.activity_project_limit,
workspace_path: state.workspace_path.clone(),
no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed,
display_style: state.display_style,
}
}
}
Expand Down Expand Up @@ -195,6 +230,7 @@ struct StoredGlobalState {
range: Option<String>,
orientation: Option<String>,
activity_project_limit: Option<usize>,
display_style: Option<String>,
last_workspace_path: Option<String>,
updated_at: i64,
}
Expand Down Expand Up @@ -443,6 +479,9 @@ 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,
display_style: restored_ui_state.display_style,
system_locale: config.system_locale.clone(),
ui_hit_targets: Vec::new(),
usage: None,
usage_updated_at: None,
usage_error: None,
Expand Down Expand Up @@ -564,6 +603,19 @@ fn handle_input_event(
usage_refresh_tx: &mpsc::Sender<()>,
limits_refresh_tx: &mpsc::Sender<()>,
) -> Result<InputOutcome> {
if let Event::Mouse(mouse) = &event {
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)
{
let changed = apply_ui_click_action(state, action);
return Ok(InputOutcome::Continue(changed));
}
}
}

if let Event::Key(key) = &event {
if key.kind != KeyEventKind::Press {
return Ok(InputOutcome::Continue(false));
Expand All @@ -576,6 +628,10 @@ fn handle_input_event(
state.active_screen = next_active_screen(state.active_screen);
return Ok(InputOutcome::Continue(true));
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) => {
state.display_style = state.display_style.toggled();
return Ok(InputOutcome::Continue(true));
}
(KeyCode::Char('r'), _) | (KeyCode::F(5), _)
if state.active_screen == ActiveScreen::Read =>
{
Expand Down Expand Up @@ -623,6 +679,60 @@ fn handle_input_event(
}
}

fn ui_click_action_at(targets: &[UiHitTarget], column: u16, row: u16) -> Option<UiClickAction> {
targets
.iter()
.find(|target| target.contains(column, row))
.map(|target| target.action)
}

fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool {
match action {
UiClickAction::SetScreen(screen) => {
let changed = state.active_screen != screen;
state.active_screen = screen;
changed
}
UiClickAction::SetMetric(metric) => {
let changed = state.metric != metric;
state.metric = metric;
changed
}
UiClickAction::SetRange(range) => {
let changed = state.range != range;
state.range = range;
changed
}
UiClickAction::SetOrientation(orientation) => {
let changed = state.orientation != orientation;
state.orientation = orientation;
changed
}
UiClickAction::DecreaseProjects => {
let next = state
.activity_project_limit
.saturating_sub(1)
.max(MIN_ACTIVITY_PROJECT_LIMIT);
let changed = next != state.activity_project_limit;
state.activity_project_limit = next;
changed
}
UiClickAction::IncreaseProjects => {
let next = state
.activity_project_limit
.saturating_add(1)
.min(MAX_ACTIVITY_PROJECT_LIMIT);
let changed = next != state.activity_project_limit;
state.activity_project_limit = next;
changed
}
UiClickAction::CycleDisplayStyle => {
state.display_style = state.display_style.toggled();
true
}
}
}

fn map_event_to_usage_cmd(event: Event) -> Option<UsageCommand> {
match event {
Event::Key(key) => {
Expand Down Expand Up @@ -882,6 +992,11 @@ fn load_persisted_ui_state(
state.activity_project_limit =
limit.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT);
}
if let Some(style_text) = store.global.display_style.as_deref() {
if let Some(style) = DisplayStyle::from_store(style_text) {
state.display_style = style;
}
}

if let Some(workspace_path) = state.workspace_path.as_ref() {
let workspace_key = workspace_path.to_string_lossy();
Expand Down Expand Up @@ -909,6 +1024,7 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul
.activity_project_limit
.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT),
);
store.global.display_style = Some(state.display_style.store_value().to_string());
store.global.last_workspace_path = workspace_path_text.clone();
store.global.updated_at = now;

Expand Down Expand Up @@ -1008,6 +1124,10 @@ fn unix_time_seconds() -> i64 {
}

impl AppState {
pub(crate) fn formatter(&self) -> DisplayFormatter<'_> {
DisplayFormatter::new(self.display_style, &self.system_locale)
}

pub(crate) fn usage_updated_label(&self) -> Option<String> {
let updated_at = self.usage_updated_at?;
Some(crate::ui::format_updated_label(updated_at))
Expand All @@ -1026,6 +1146,25 @@ mod tests {

static TEMP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);

#[test]
fn ui_hit_targets_include_left_top_and_exclude_right_bottom_edges() {
let targets = [UiHitTarget {
area: Rect::new(10, 5, 4, 2),
action: UiClickAction::SetMetric(UsageMetric::Runs),
}];

assert_eq!(
ui_click_action_at(&targets, 10, 5),
Some(UiClickAction::SetMetric(UsageMetric::Runs))
);
assert_eq!(
ui_click_action_at(&targets, 13, 6),
Some(UiClickAction::SetMetric(UsageMetric::Runs))
);
assert_eq!(ui_click_action_at(&targets, 14, 5), None);
assert_eq!(ui_click_action_at(&targets, 10, 7), None);
}

fn make_temp_dir(prefix: &str) -> PathBuf {
let unique = format!(
"{}-{}-{}",
Expand Down Expand Up @@ -1094,4 +1233,42 @@ mod tests {

let _ = std::fs::remove_dir_all(comon_home);
}

#[test]
fn legacy_state_without_display_style_defaults_to_classic() {
let comon_home = make_temp_dir("legacy-display-style");
let store = StateStore::default();
write_state_store(&comon_home, &store).expect("write state store");

let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state");
assert_eq!(loaded.display_style, DisplayStyle::Classic);

let _ = std::fs::remove_dir_all(comon_home);
}

#[test]
fn display_style_round_trips_through_state_store() {
let comon_home = make_temp_dir("system-display-style");
let mut state = PersistedUiState::default_for_workspace(None);
state.display_style = DisplayStyle::SystemFull;

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_eq!(loaded.display_style, DisplayStyle::SystemFull);

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");
let mut store = StateStore::default();
store.global.display_style = Some("system".to_string());
write_state_store(&comon_home, &store).expect("write state store");

let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state");
assert_eq!(loaded.display_style, DisplayStyle::SystemCompact);

let _ = std::fs::remove_dir_all(comon_home);
}
}
Loading
Loading