Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
37f9a59
refactor(tui): reorganize overview, sessions, and daily views
makoMakoGo Jul 17, 2026
5103ac6
fix(tui): remove obsolete view state and format new views
makoMakoGo Jul 17, 2026
7da7223
fix(tui): prevent stale overlays and align responsive views
makoMakoGo Jul 17, 2026
9e0d1e7
fix(tui): simplify overview snapshot presentation
makoMakoGo Jul 17, 2026
0e7f1bc
feat(tui): expand daily profile with shared usage summary
makoMakoGo Jul 17, 2026
7f326c0
fix(tui): surface non-blocking Sessions refresh failures
makoMakoGo Jul 17, 2026
b3e5dda
perf(tui): reuse unchanged Sessions projections
makoMakoGo Jul 17, 2026
ea0def6
perf(tui): share indexed Sessions snapshots
makoMakoGo Jul 17, 2026
9b2f68d
fix(tui): register clicks only for dated stats cells
makoMakoGo Jul 17, 2026
ad61509
fix(tui): render state-aware footers in one pass
makoMakoGo Jul 17, 2026
33ccfb9
docs(tui): document compact Snapshot metric priority
makoMakoGo Jul 17, 2026
51c1a5a
fix(tui): fit Overview legend to available width
makoMakoGo Jul 17, 2026
cd8a183
test(tui): cover Sessions detail footer hitboxes
makoMakoGo Jul 17, 2026
1e885d7
fix(tui): surface pricing failures in the global footer
makoMakoGo Jul 17, 2026
c7624c9
fix(tui): route mouse wheel through stateful views
makoMakoGo Jul 17, 2026
80f214d
fix(cli): expose sessions as the TUI tab name
makoMakoGo Jul 17, 2026
0456e0a
fix(tui): make daily profile scroll on short terminals
makoMakoGo Jul 17, 2026
62cff94
fix(ci): fetch PR base history for release validation
makoMakoGo Jul 17, 2026
6ad00ae
fix: isolate TUI tests and preserve custom cached pricing
makoMakoGo Jul 17, 2026
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
31 changes: 0 additions & 31 deletions crates/tokscale-cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,6 @@ pub struct App {
usage_text_total_lines: usize,
pub(crate) hourly_profile_viewport: TextViewport,
hourly_profile_text_total_lines: usize,
pub(crate) issues_viewport: TextViewport,
issues_text_total_lines: usize,
pub selected_daily_detail_date: Option<NaiveDate>,
pub selected_period_detail: Option<PeriodDetailSelection>,
detail_sort_contexts: HashMap<DetailSortContextKind, DetailSortContext>,
Expand Down Expand Up @@ -526,8 +524,6 @@ impl App {
usage_text_total_lines: 0,
hourly_profile_viewport: TextViewport::default(),
hourly_profile_text_total_lines: 0,
issues_viewport: TextViewport::default(),
issues_text_total_lines: 0,
selected_daily_detail_date: None,
selected_period_detail: None,
detail_sort_contexts: HashMap::new(),
Expand Down Expand Up @@ -1078,23 +1074,12 @@ impl App {
.visible_range(self.hourly_profile_text_total_lines)
}

pub(crate) fn set_issues_text_viewport(&mut self, visible: usize, total_lines: usize) {
self.issues_text_total_lines = total_lines;
self.issues_viewport.set_visible(visible, total_lines);
}

pub(crate) fn issues_text_visible_range(&self) -> std::ops::Range<usize> {
self.issues_viewport
.visible_range(self.issues_text_total_lines)
}

fn active_text_viewport_mut(&mut self) -> Option<&mut TextViewport> {
match self.current_tab {
Tab::Usage => Some(&mut self.usage_viewport),
Tab::Hourly if self.hourly_view_mode == HourlyViewMode::Profile => {
Some(&mut self.hourly_profile_viewport)
}
Tab::Issues => Some(&mut self.issues_viewport),
_ => None,
}
}
Expand All @@ -1105,7 +1090,6 @@ impl App {
Tab::Hourly if self.hourly_view_mode == HourlyViewMode::Profile => {
Some(self.hourly_profile_text_total_lines)
}
Tab::Issues => Some(self.issues_text_total_lines),
_ => None,
}
}
Expand Down Expand Up @@ -3668,21 +3652,6 @@ mod tests {
assert_eq!(app.scroll_offset, 1);
}

#[test]
fn issues_tab_key_scrolls_text_viewport_without_table_selection() {
let mut app = make_app();
app.current_tab = Tab::Issues;
app.selected_index = 2;
app.scroll_offset = 1;
app.set_issues_text_viewport(4, 10);

app.handle_key_event(key(KeyCode::PageDown));

assert_eq!(app.issues_viewport.scroll, 2);
assert_eq!(app.selected_index, 2);
assert_eq!(app.scroll_offset, 1);
}

#[test]
fn usage_tab_mouse_wheel_scrolls_text_viewport_without_table_selection() {
let mut app = make_app_with_usage();
Expand Down
4 changes: 1 addition & 3 deletions crates/tokscale-cli/src/tui/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ pub use tokscale_core::usage_views::{
HourlyModelInfo, HourlyUsage, PeriodKind, PeriodUsage, UsageData, UsageGraphData as GraphData,
UsageModelEntry as ModelUsage, UsageTokenBreakdown as TokenBreakdown,
};
pub use tokscale_core::{
aggregate_by_period, aggregate_by_weekday, build_period_usage, find_peak_hour,
};
pub use tokscale_core::{aggregate_by_period, build_period_usage, find_peak_hour};

/// Returns the scanner settings that `DataLoader` should use when building
/// `LocalParseOptions`. Under `#[cfg(test)]` this intentionally ignores
Expand Down
53 changes: 36 additions & 17 deletions crates/tokscale-cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ pub mod data;
mod event;
mod export;
mod interaction;
mod session_data;
pub mod settings;
mod themes;
mod ui;
mod view_state;

use app::KeyEventOutcome;
pub use app::{App, Tab, TuiConfig, TuiExit};
Expand Down Expand Up @@ -52,9 +54,10 @@ use tokscale_core::{

fn decide_initial_data(load_result: CacheResult) -> (Option<UsageData>, bool, Option<u64>) {
match load_result {
CacheResult::Fresh(data, signature) => {
(Some(data), false, Some(signature.process_digest()))
}
// The cached TUI bundle does not persist the independent Sessions projection.
// Keep rendering it immediately, then run the inventory probe in the background
// so Sessions can be refreshed without forcing the main usage aggregation.
CacheResult::Fresh(data, signature) => (Some(data), true, Some(signature.process_digest())),
CacheResult::Stale(data) => (Some(data), true, None),
CacheResult::Miss => (None, true, None),
}
Expand Down Expand Up @@ -88,6 +91,12 @@ enum BackgroundLoad {
},
}

fn refresh_session_data(loader: &DataLoader, clients: &[ClientId]) {
if let Err(error) = session_data::refresh(loader, clients) {
tracing::warn!(error = %error, "failed to refresh TUI Sessions projection");
Comment thread
makoMakoGo marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

fn load_background_data(
loader: &DataLoader,
clients: &[ClientId],
Expand All @@ -100,17 +109,19 @@ fn load_background_data(
.refresh_source_inventory_signature()?
.process_digest();
if !force && last_digest == Some(digest) {
refresh_session_data(loader, clients);
return Ok(BackgroundLoad::Unchanged);
Comment thread
makoMakoGo marked this conversation as resolved.
Resolved
}
loader
.execute_with_diagnostics(prepared, group_by)
.map(|result| BackgroundLoad::Loaded {
data: Box::new(result.data),
digest: result.source_digest,
source_inventory_signature: result.source_inventory_signature,
pricing_diagnostics: result.pricing_diagnostics,
cache_persistence_warning: None,
})

let result = loader.execute_with_diagnostics(prepared, group_by);
refresh_session_data(loader, clients);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
result.map(|result| BackgroundLoad::Loaded {
data: Box::new(result.data),
digest: result.source_digest,
source_inventory_signature: result.source_inventory_signature,
pricing_diagnostics: result.pricing_diagnostics,
cache_persistence_warning: None,
})
}

fn persist_background_load(
Expand Down Expand Up @@ -313,6 +324,7 @@ pub fn run(
}
};
app.last_source_digest = initial_source_digest;
let mut view_state = view_state::ViewState::default();

let (bg_tx, bg_rx) = mpsc::channel::<Result<BackgroundLoad>>();

Expand All @@ -329,11 +341,13 @@ pub fn run(
let bg_enabled_clients = enabled_clients.clone();
let bg_group_by = app.group_by.borrow().clone();
let bg_report_scope = background_cache_scope(&home_dir, &since, &until, &year)?;
let bg_last_digest = initial_source_digest;
let bg_force = bg_last_digest.is_none();

thread::spawn(move || {
let loader = background_data_loader(bg_home_dir, bg_since, bg_until, bg_year);
let result = persist_background_load(
load_background_data(&loader, &bg_clients, &bg_group_by, true, None),
load_background_data(&loader, &bg_clients, &bg_group_by, bg_force, bg_last_digest),
&bg_enabled_clients,
&bg_group_by,
&bg_report_scope,
Expand All @@ -359,6 +373,7 @@ pub fn run(
let result = run_loop_with_background(
&mut terminal,
&mut app,
&mut view_state,
&mut events,
bg_tx,
bg_rx,
Expand Down Expand Up @@ -395,6 +410,7 @@ fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) {
fn run_loop_with_background(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
view_state: &mut view_state::ViewState,
events: &mut EventHandler,
bg_tx: mpsc::Sender<Result<BackgroundLoad>>,
bg_rx: mpsc::Receiver<Result<BackgroundLoad>>,
Expand All @@ -412,7 +428,7 @@ fn run_loop_with_background(
let _ = terminal.clear();
}

terminal.draw(|f| ui::render(f, app))?;
terminal.draw(|frame| ui::render_with_state(frame, app, view_state))?;

match bg_rx.try_recv() {
Ok(result) => {
Expand Down Expand Up @@ -466,6 +482,9 @@ fn run_loop_with_background(
app.on_tick();
}
Event::Key(key) => {
if view_state.handle_key(app, &key) {
continue;
Comment thread
makoMakoGo marked this conversation as resolved.
Resolved
}
if let KeyEventOutcome::Exit(exit) = app.handle_key_event(key) {
return Ok(exit);
}
Expand Down Expand Up @@ -547,14 +566,14 @@ mod tests {
}

#[test]
fn launches_with_fresh_cache_skips_immediate_background_load() {
fn launches_with_fresh_cache_refreshes_session_projection_in_background() {
let (cached_data, needs_background_load, digest) = decide_initial_data(CacheResult::Fresh(
UsageData::default(),
tokscale_core::SourceInventorySignature::from_bytes([1; 32]),
));

assert!(cached_data.is_some());
assert!(!needs_background_load);
assert!(needs_background_load);
assert!(digest.is_some());
}

Expand Down Expand Up @@ -624,7 +643,7 @@ mod tests {
.source_inventory_signature;
let (_, needs_load, baseline) =
decide_initial_data(CacheResult::Fresh(UsageData::default(), signature_a));
assert!(!needs_load);
assert!(needs_load);

assert!(matches!(
load_background_data(
Expand Down
Loading
Loading