Skip to content
443 changes: 437 additions & 6 deletions crates/tokscale-cli/src/main.rs

Large diffs are not rendered by default.

113 changes: 105 additions & 8 deletions crates/tokscale-cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
use ratatui::layout::Rect;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Mouse-based tab switching bypasses apply_tab_sort_defaults, so entering Hourly by click can preserve stale sort state instead of defaulting to newest-first.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/tokscale-cli/src/tui/app.rs, line 517:

<comment>Mouse-based tab switching bypasses `apply_tab_sort_defaults`, so entering Hourly by click can preserve stale sort state instead of defaulting to newest-first.</comment>

<file context>
@@ -506,8 +510,12 @@ impl App {
-        // Hourly tab defaults to newest-first; other tabs keep cost sort
+    /// Apply per-tab sort defaults when switching tabs.
+    /// Must be called AFTER updating `self.current_tab`, before `reset_selection`.
+    fn apply_tab_sort_defaults(&mut self) {
+        // Hourly tab shows time-ordered data by default; other tabs keep cost sort.
         if self.current_tab == Tab::Hourly {
</file context>
Fix with Cubic

use tokscale_core::ClientId;

use super::data::{AgentUsage, DailyUsage, DataLoader, ModelUsage, UsageData};
use super::data::{AgentUsage, DailyUsage, DataLoader, HourlyUsage, ModelUsage, UsageData};
use super::settings::Settings;
use super::themes::{Theme, ThemeName};
use super::ui::dialog::{ClientPickerDialog, DialogStack};
Expand All @@ -30,6 +30,7 @@ pub enum Tab {
Overview,
Models,
Daily,
Hourly,
Stats,
Agents,
}
Expand All @@ -40,6 +41,7 @@ impl Tab {
Tab::Overview,
Tab::Models,
Tab::Daily,
Tab::Hourly,
Tab::Stats,
Tab::Agents,
]
Expand All @@ -50,6 +52,7 @@ impl Tab {
Tab::Overview => "Overview",
Tab::Models => "Models",
Tab::Daily => "Daily",
Tab::Hourly => "Hourly",
Tab::Stats => "Stats",
Tab::Agents => "Agents",
}
Expand All @@ -60,6 +63,7 @@ impl Tab {
Tab::Overview => "Ovw",
Tab::Models => "Mod",
Tab::Daily => "Day",
Tab::Hourly => "Hr",
Tab::Stats => "Sta",
Tab::Agents => "Agt",
}
Expand All @@ -69,7 +73,8 @@ impl Tab {
match self {
Tab::Overview => Tab::Models,
Tab::Models => Tab::Daily,
Tab::Daily => Tab::Stats,
Tab::Daily => Tab::Hourly,
Tab::Hourly => Tab::Stats,
Tab::Stats => Tab::Agents,
Tab::Agents => Tab::Overview,
}
Expand All @@ -80,12 +85,20 @@ impl Tab {
Tab::Overview => Tab::Agents,
Tab::Models => Tab::Overview,
Tab::Daily => Tab::Models,
Tab::Stats => Tab::Daily,
Tab::Hourly => Tab::Daily,
Tab::Stats => Tab::Hourly,
Tab::Agents => Tab::Stats,
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChartGranularity {
#[default]
Daily,
Hourly,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortField {
Cost,
Expand Down Expand Up @@ -124,6 +137,7 @@ pub struct App {
pub group_by: Rc<RefCell<tokscale_core::GroupBy>>,
pub sort_field: SortField,
pub sort_direction: SortDirection,
pub chart_granularity: ChartGranularity,

pub scroll_offset: usize,
pub selected_index: usize,
Expand Down Expand Up @@ -215,6 +229,7 @@ impl App {
group_by: Rc::new(RefCell::new(tokscale_core::GroupBy::Model)),
sort_field: SortField::Cost,
sort_direction: SortDirection::Descending,
chart_granularity: ChartGranularity::default(),
scroll_offset: 0,
selected_index: 0,
max_visible_items: 20,
Expand Down Expand Up @@ -305,18 +320,22 @@ impl App {
}
KeyCode::Tab => {
self.current_tab = self.current_tab.next();
self.apply_tab_sort_defaults();
self.reset_selection();
}
KeyCode::BackTab => {
self.current_tab = self.current_tab.prev();
self.apply_tab_sort_defaults();
self.reset_selection();
}
KeyCode::Left => {
self.current_tab = self.current_tab.prev();
self.apply_tab_sort_defaults();
self.reset_selection();
}
KeyCode::Right => {
self.current_tab = self.current_tab.next();
self.apply_tab_sort_defaults();
self.reset_selection();
}
KeyCode::Up => {
Expand Down Expand Up @@ -377,6 +396,14 @@ impl App {
KeyCode::Char('s') => {
self.open_client_picker();
}
KeyCode::Char('h') => {
if self.current_tab == Tab::Overview {
self.chart_granularity = match self.chart_granularity {
ChartGranularity::Daily => ChartGranularity::Hourly,
ChartGranularity::Hourly => ChartGranularity::Daily,
};
}
}
KeyCode::Char('g') => {
self.open_group_by_picker();
}
Expand Down Expand Up @@ -485,6 +512,16 @@ impl App {
self.stats_breakdown_total_lines = 0;
}

/// Apply per-tab sort defaults when switching tabs.
/// Must be called AFTER updating `self.current_tab`, before `reset_selection`.
fn apply_tab_sort_defaults(&mut self) {
// Hourly tab shows time-ordered data by default; other tabs keep cost sort.
if self.current_tab == Tab::Hourly {
self.sort_field = SortField::Date;
self.sort_direction = SortDirection::Descending;
}
}

fn move_selection_up(&mut self) {
if self.current_tab == Tab::Stats && self.selected_graph_cell.is_some() {
let len = self.get_current_list_len();
Expand Down Expand Up @@ -597,6 +634,7 @@ impl App {
Tab::Overview | Tab::Models => self.data.models.len(),
Tab::Agents => self.data.agents.len(),
Tab::Daily => self.data.daily.len(),
Tab::Hourly => self.data.hourly.len(),
Tab::Stats => {
if self.selected_graph_cell.is_some() {
self.stats_breakdown_total_lines
Expand Down Expand Up @@ -750,6 +788,17 @@ impl App {
.get_sorted_daily()
.get(self.selected_index)
.map(|d| format!("{}: {} tokens, ${:.4}", d.date, d.tokens.total(), d.cost)),
Tab::Hourly => self
.get_sorted_hourly()
.get(self.selected_index)
.map(|h| {
format!(
"{}: {} tokens, ${:.4}",
h.datetime.format("%Y-%m-%d %H:%M"),
h.tokens.total(),
h.cost
)
}),
Tab::Stats => None,
};

Expand Down Expand Up @@ -799,6 +848,8 @@ impl App {
"cacheWrite": d.tokens.cache_write,
"total": d.tokens.total()
},
"messageCount": d.message_count,
"turnCount": d.turn_count,
"cost": d.cost
})).collect::<Vec<_>>(),
"totals": {
Expand Down Expand Up @@ -938,6 +989,43 @@ impl App {
daily
}

pub fn get_sorted_hourly(&self) -> Vec<&HourlyUsage> {
let mut hourly: Vec<&HourlyUsage> = self.data.hourly.iter().collect();

match (self.sort_field, self.sort_direction) {
(SortField::Cost, SortDirection::Descending) => hourly.sort_by(|a, b| {
b.cost
.total_cmp(&a.cost)
.then_with(|| a.datetime.cmp(&b.datetime))
}),
(SortField::Cost, SortDirection::Ascending) => hourly.sort_by(|a, b| {
a.cost
.total_cmp(&b.cost)
.then_with(|| a.datetime.cmp(&b.datetime))
}),
(SortField::Tokens, SortDirection::Descending) => hourly.sort_by(|a, b| {
b.tokens
.total()
.cmp(&a.tokens.total())
.then_with(|| a.datetime.cmp(&b.datetime))
}),
(SortField::Tokens, SortDirection::Ascending) => hourly.sort_by(|a, b| {
a.tokens
.total()
.cmp(&b.tokens.total())
.then_with(|| a.datetime.cmp(&b.datetime))
}),
(SortField::Date, SortDirection::Descending) => {
hourly.sort_by(|a, b| b.datetime.cmp(&a.datetime))
}
(SortField::Date, SortDirection::Ascending) => {
hourly.sort_by(|a, b| a.datetime.cmp(&b.datetime))
}
}

hourly
}

pub fn is_narrow(&self) -> bool {
self.terminal_width < 80
}
Expand All @@ -955,19 +1043,21 @@ mod tests {
#[test]
fn test_tab_all() {
let tabs = Tab::all();
assert_eq!(tabs.len(), 5);
assert_eq!(tabs.len(), 6);
assert_eq!(tabs[0], Tab::Overview);
assert_eq!(tabs[1], Tab::Models);
assert_eq!(tabs[2], Tab::Daily);
assert_eq!(tabs[3], Tab::Stats);
assert_eq!(tabs[4], Tab::Agents);
assert_eq!(tabs[3], Tab::Hourly);
assert_eq!(tabs[4], Tab::Stats);
assert_eq!(tabs[5], Tab::Agents);
}

#[test]
fn test_tab_next() {
assert_eq!(Tab::Overview.next(), Tab::Models);
assert_eq!(Tab::Models.next(), Tab::Daily);
assert_eq!(Tab::Daily.next(), Tab::Stats);
assert_eq!(Tab::Daily.next(), Tab::Hourly);
assert_eq!(Tab::Hourly.next(), Tab::Stats);
assert_eq!(Tab::Stats.next(), Tab::Agents);
assert_eq!(Tab::Agents.next(), Tab::Overview);
}
Expand All @@ -977,7 +1067,8 @@ mod tests {
assert_eq!(Tab::Overview.prev(), Tab::Agents);
assert_eq!(Tab::Models.prev(), Tab::Overview);
assert_eq!(Tab::Daily.prev(), Tab::Models);
assert_eq!(Tab::Stats.prev(), Tab::Daily);
assert_eq!(Tab::Hourly.prev(), Tab::Daily);
assert_eq!(Tab::Stats.prev(), Tab::Hourly);
assert_eq!(Tab::Agents.prev(), Tab::Stats);
}

Expand Down Expand Up @@ -1268,6 +1359,9 @@ mod tests {
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Daily);

app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Hourly);

app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Stats);

Expand All @@ -1289,6 +1383,9 @@ mod tests {
app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Stats);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Hourly);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Daily);

Expand Down
Loading
Loading