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
11 changes: 8 additions & 3 deletions src/db/usage_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@ impl Store {
Ok(true)
}

pub(crate) fn list_usage_events(
pub(crate) fn fold_usage_events<T>(
&self,
sources: Option<&[String]>,
time_range: TimeRange,
) -> Result<Vec<UsageEventRecord>> {
mut state: T,
mut fold: impl FnMut(&mut T, UsageEventRecord),
) -> Result<T> {
let mut sql = String::from(
"SELECT session_id, source, source_id, event_key, timestamp, model, provider,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
Expand Down Expand Up @@ -182,7 +184,10 @@ impl Store {
})
})?;

rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
for row in rows {
fold(&mut state, row?);
}
Ok(state)
}

pub(crate) fn list_usage_events_for_session(
Expand Down
181 changes: 136 additions & 45 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::db::store::{ProjectDirectory, Store};
use crate::handoff;
use crate::project_scope::ProjectScope;
use crate::session_action;
use crate::skill_audit::{self, SkillAuditFilters, SkillAuditReport};
use crate::skill_audit::SkillAuditReport;
use crate::transcript;
use crate::tui::layout::{
MessagePane, SearchLayout, ViewingLayout, search_layout, vertical_scrollbar_position,
Expand All @@ -26,14 +26,15 @@ use crate::tui::share_state::{
};
use crate::tui::text_layout::wrap_visual_rows;
use crate::tui::usage_state::UsageTab;
use crate::tui::usage_worker::{UsageRequest, UsageResponse};
use crate::tui::viewing_state::{
SanitizedLine, ViewingFrame, ViewingLineage, ViewingParent, ViewingSessionSummary,
build_viewing_caches,
};
use crate::types::{
BackgroundJobStatus, MatchSource, Message, SearchResult, SemanticProgress, Session,
};
use crate::usage::{self, UsageFilters, UsageReport};
use crate::usage::UsageReport;

const USAGE_LOADING_MIN_MS: u128 = 75;
const SEARCH_DEBOUNCE_MS: u64 = 250;
Expand Down Expand Up @@ -198,6 +199,9 @@ pub(crate) struct App {
pub(crate) usage_error: Option<String>,
pub(crate) usage_time_filter: TimeRange,
pub(crate) usage_refresh_requested_at: Option<Instant>,
pub(crate) usage_in_flight: bool,
pub(crate) usage_request_id: u64,
pub(crate) active_usage_request_id: u64,
pub(crate) usage_breakdown_scroll: u16,
pub(crate) usage_tab: UsageTab,
pub(crate) skill_audit_report: Option<SkillAuditReport>,
Expand Down Expand Up @@ -290,6 +294,9 @@ impl App {
usage_error: None,
usage_time_filter: TimeRange::All,
usage_refresh_requested_at: None,
usage_in_flight: false,
usage_request_id: 0,
active_usage_request_id: 0,
usage_breakdown_scroll: 0,
usage_tab: UsageTab::Tokens,
skill_audit_report: None,
Expand Down Expand Up @@ -1199,7 +1206,7 @@ impl App {
self.reset_usage_dashboard();
self.request_usage_refresh();
}
KeyCode::Enter if self.usage_tab == UsageTab::Skills => {
KeyCode::Enter if self.usage_tab == UsageTab::Skills && !self.usage_is_loading() => {
self.open_skill_sessions(store);
}
KeyCode::Up | KeyCode::Char('k') => self.handle_scroll_up(store),
Expand Down Expand Up @@ -1951,60 +1958,33 @@ impl App {
}
}

pub(crate) fn refresh_usage(&mut self, store: &Store) {
self.usage_refresh_requested_at = None;
self.usage_error = None;
self.skill_audit_error = None;
self.usage_breakdown_scroll = 0;
self.skill_audit_selected = 0;
let sources = self.source_filter_ids();
let current_filters =
UsageFilters { sources: sources.clone(), time_range: self.usage_time_filter };
match usage::build_usage_report(store, &current_filters) {
Ok(report) => {
self.usage_report = Some(report);
}
Err(err) => {
self.usage_report = None;
self.usage_error = Some(format!("Usage unavailable: {err}"));
}
}

let year_filters = UsageFilters { sources, time_range: TimeRange::All };
match usage::build_usage_report(store, &year_filters) {
Ok(report) => {
self.usage_year_report = Some(report);
}
Err(err) => {
self.usage_year_report = None;
if self.usage_error.is_none() {
self.usage_error = Some(format!("Usage unavailable: {err}"));
}
}
pub(crate) fn take_usage_request(&mut self, sync: bool) -> Option<UsageRequest> {
if !self.usage_refresh_is_due() {
return None;
}

let skill_filters = SkillAuditFilters {
self.usage_refresh_requested_at = None;
self.usage_in_flight = true;
Comment thread
samzong marked this conversation as resolved.
Some(UsageRequest {
id: self.active_usage_request_id,
sources: self.source_filter_ids(),
time_range: self.usage_time_filter,
};
match skill_audit::build_skill_audit_report(store, &skill_filters) {
Ok(report) => {
self.skill_audit_report = Some(report);
}
Err(err) => {
self.skill_audit_report = None;
self.skill_audit_error = Some(format!("Skill audit unavailable: {err}"));
}
}
sync,
})
}

pub(crate) fn request_usage_refresh(&mut self) {
self.usage_error = None;
self.skill_audit_error = None;
self.usage_request_id = self.usage_request_id.saturating_add(1);
self.active_usage_request_id = self.usage_request_id;
self.usage_refresh_requested_at = Some(Instant::now());
self.usage_breakdown_scroll = 0;
self.skill_audit_selected = 0;
}

pub(crate) fn usage_is_loading(&self) -> bool {
self.usage_refresh_requested_at.is_some()
self.usage_refresh_requested_at.is_some() || self.usage_in_flight
}

pub(crate) fn usage_refresh_is_due(&self) -> bool {
Expand All @@ -2015,13 +1995,50 @@ impl App {

pub(crate) fn fail_usage_refresh(&mut self, error: impl std::fmt::Display) {
self.usage_refresh_requested_at = None;
self.usage_in_flight = false;
self.usage_report = None;
self.usage_year_report = None;
self.skill_audit_report = None;
self.usage_error = Some(format!("Usage unavailable: {error}"));
self.skill_audit_error = Some(format!("Skill audit unavailable: {error}"));
}

pub(crate) fn apply_usage_response(&mut self, response: UsageResponse) {
if response.id != self.active_usage_request_id
|| response.sources != self.source_filter_ids()
|| response.time_range != self.usage_time_filter
{
return;
}

self.usage_in_flight = false;
match response.current_report {
Ok(report) => self.usage_report = Some(report),
Err(error) => {
self.usage_report = None;
self.usage_error = Some(format!("Usage unavailable: {error}"));
}
}

match response.all_time_report {
Ok(report) => self.usage_year_report = Some(report),
Err(error) => {
self.usage_year_report = None;
if self.usage_error.is_none() {
self.usage_error = Some(format!("Usage unavailable: {error}"));
}
}
}

match response.skill_audit_report {
Ok(report) => self.skill_audit_report = Some(report),
Err(error) => {
self.skill_audit_report = None;
self.skill_audit_error = Some(format!("Skill audit unavailable: {error}"));
}
}
}

pub(crate) fn try_search(&mut self, store: &Store, worker: &SearchWorker) {
self.refresh_semantic_progress(store);
if !self.search_pending {
Expand Down Expand Up @@ -2829,6 +2846,9 @@ mod tests {
usage_error: None,
usage_time_filter: TimeRange::All,
usage_refresh_requested_at: None,
usage_in_flight: false,
usage_request_id: 0,
active_usage_request_id: 0,
usage_breakdown_scroll: 0,
usage_tab: UsageTab::Tokens,
skill_audit_report: None,
Expand Down Expand Up @@ -3861,4 +3881,75 @@ mod tests {
assert!(app.filters_dirty);
assert!(!app.search_pending);
}

#[test]
fn usage_refresh_yields_background_request() {
let mut app = app_with_sources();
app.request_usage_refresh();
app.usage_refresh_requested_at = Some(Instant::now() - Duration::from_millis(100));

let request = app.take_usage_request(false).expect("usage request");

assert_eq!(request.time_range, TimeRange::All);
assert!(!request.sync);
assert!(app.usage_is_loading());
}

#[test]
fn stale_usage_response_does_not_replace_latest_filter_state() {
use crate::tui::usage_worker::UsageResponse;

let mut app = app_with_sources();
app.source_filter_selection = vec!["codex".to_string()];
app.usage_time_filter = TimeRange::Month;
app.usage_request_id = 1;
app.active_usage_request_id = 1;
app.request_usage_refresh();

app.apply_usage_response(UsageResponse {
id: 1,
sources: Some(vec!["codex".to_string()]),
time_range: TimeRange::Month,
current_report: Err("stale current report".to_string()),
all_time_report: Err("stale all-time report".to_string()),
skill_audit_report: Err("stale skill audit".to_string()),
});

assert_eq!(app.active_usage_request_id, 2);
assert!(app.usage_error.is_none());
assert!(app.skill_audit_error.is_none());
assert_eq!(app.usage_time_filter, TimeRange::Month);
}

#[test]
fn skill_drill_down_is_disabled_while_usage_is_loading() {
use crate::skill_audit::{SkillAuditSummary, SkillTier, SkillUsageEntry};

crate::db::schema::register_sqlite_vec();
let store = Store::open_in_memory().unwrap();
let mut app = app_with_sources();
app.mode = AppMode::Usage;
app.usage_tab = UsageTab::Skills;
app.usage_in_flight = true;
app.skill_audit_report = Some(SkillAuditReport {
summary: SkillAuditSummary { installed: 1, core: 0, occasional: 1, dormant: 0 },
core: Vec::new(),
occasional: vec![SkillUsageEntry {
id: "stale-skill".to_string(),
tier: SkillTier::Occasional,
invocations: 1,
last_used: None,
signals: Vec::new(),
install_path: None,
session_ids: vec!["stale-session".to_string()],
}],
dormant: Vec::new(),
coverage_note: None,
});

app.handle_usage_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &store);

assert!(matches!(app.mode, AppMode::Usage));
assert!(app.status_message.is_none());
}
}
1 change: 1 addition & 0 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ pub(crate) mod text_layout;
pub(crate) mod theme;
pub(crate) mod ui;
pub(crate) mod usage_state;
pub(crate) mod usage_worker;
pub(crate) mod viewing_state;
22 changes: 12 additions & 10 deletions src/tui/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use crate::config::AppConfig;
use crate::db::search::TimeRange;
use crate::db::store::Store;
use crate::semantic;
use crate::sync::run_dashboard_sync_job;
use crate::tui::search_worker::SearchWorker;
use crate::tui::usage_worker::UsageWorker;

pub(crate) fn run(usage_start: Option<(Option<Vec<String>>, Option<TimeRange>)>) -> Result<()> {
use std::io;
Expand Down Expand Up @@ -61,6 +61,7 @@ pub(crate) fn run(usage_start: Option<(Option<Vec<String>>, Option<TimeRange>)>)
Some("Debug builds do not start semantic indexing; run cargo run -- sync first".into());
}
let search_worker = SearchWorker::spawn();
let usage_worker = UsageWorker::spawn();
if let Some((source_filter, time_filter)) = usage_start {
app.source_filter_selection = source_filter.unwrap_or_default();
if let Some(time_filter) = time_filter {
Expand All @@ -77,6 +78,9 @@ pub(crate) fn run(usage_start: Option<(Option<Vec<String>>, Option<TimeRange>)>)
while let Some(response) = search_worker.try_recv() {
app.apply_search_response(&store, response);
}
while let Some(response) = usage_worker.try_recv() {
app.apply_usage_response(response);
}
terminal.draw(|f| ui::render(f, &app))?;

let size = terminal.size()?;
Expand All @@ -97,22 +101,20 @@ pub(crate) fn run(usage_start: Option<(Option<Vec<String>>, Option<TimeRange>)>)
break;
}

if app.usage_refresh_is_due() {
if usage_sync_pending {
usage_sync_pending = false;
match run_dashboard_sync_job() {
Ok(()) => app.refresh_usage(&store),
Err(err) => app.fail_usage_refresh(err),
}
} else {
app.refresh_usage(&store);
if let Some(request) = app.take_usage_request(usage_sync_pending) {
usage_sync_pending = false;
Comment thread
samzong marked this conversation as resolved.
if !usage_worker.refresh(request) {
app.fail_usage_refresh("Usage worker unavailable");
}
}

app.try_search(&store, &search_worker);
while let Some(response) = search_worker.try_recv() {
app.apply_search_response(&store, response);
}
while let Some(response) = usage_worker.try_recv() {
app.apply_usage_response(response);
}

if app.should_quit {
break;
Expand Down
Loading