Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ Settings → **Tray icon** picks what sits next to the clock. Settings → **Tas

![The panel in the pure black dark theme and in the light theme](docs/images/themes.webp)

## Smart refresh

Settings → **Auto refresh → Smart** adjusts each account independently. After a quota change, it checks every minute for 5 minutes. Unchanged results then increase the wait to 2, 5, 10 and finally 15 minutes. Any further quota change restarts the one-minute checks. Existing fixed intervals and the default 15-minute setting stay available.

Checks are scheduled after results arrive, so a slow account does not overlap itself or block another account. Failures use a separate 2/5/10/15-minute backoff, and the existing HTTP rate-limit gate still enforces server retry delays. Opening the panel refreshes data at least a minute old, without bypassing error backoff. At the 15-minute cadence, usage elsewhere can take up to 15 minutes to be detected.

Smart polls reuse successful local token-history scans for up to 15 minutes; manual refreshes and fixed intervals still scan fresh history. Quota checks remain live. Only raw progress values and limits determine activity, so countdown text and history charts cannot keep an idle account polling rapidly. No file watchers or burn-rate prediction are involved.

## Supported Providers

- [**Amp**](docs/providers/amp.md) / free tier, bonus, credits
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ Returns a status envelope:
### Behavior

- **Runtime runners**: Executes pinned `ccusage@20.0.14` via fallback chain `bunx -> pnpm dlx -> yarn dlx -> npm exec -> npx`
- **Smart refresh**: Automatic Smart polls may reuse a successful result for up to 15 minutes, keyed by account and query options. Manual refreshes and fixed-interval polls bypass and update this cache. Failed queries are not cached. Provider quota requests remain live.
- **Provider-aware**: Resolves provider from `opts.provider` or plugin id (`claude`/`codex`)
- **Focused commands**: Uses `ccusage claude daily` or `ccusage codex daily`; it intentionally does not use `ccusage daily` because that aggregates all detected agents
- **Legacy fallback**: If `ccusage@20.0.14` cannot run through the package manager release-age policy, retries with release-age-safe `ccusage@18.0.11` for Claude or `@ccusage/codex@18.0.11` for Codex
Expand Down
8 changes: 7 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ async fn start_probe_batch(
state: tauri::State<'_, Mutex<AppState>>,
batch_id: Option<String>,
plugin_ids: Option<Vec<String>>,
cache_local_usage: Option<bool>,
) -> Result<ProbeBatchStarted, String> {
let batch_id = batch_id
.and_then(|id| {
Expand Down Expand Up @@ -325,7 +326,12 @@ async fn start_probe_batch(
wsl::stale_output(&plugin, local_http_api::cached_snapshot(&plugin_id))
} else {
catch_probe_panic(&plugin, || {
plugin_engine::runtime::run_probe(&plugin, &data_dir, &version)
plugin_engine::runtime::run_probe_with_cache(
&plugin,
&data_dir,
&version,
cache_local_usage.unwrap_or(false),
)
})
};
// The account may have been removed while this probe was queued: drop its result
Expand Down
96 changes: 96 additions & 0 deletions src-tauri/src/plugin_engine/ccusage_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Smart polls reuse successful local-history scans for 15 minutes. Manual/fixed polls stay fresh.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

const TTL: Duration = Duration::from_secs(15 * 60);

struct Entry {
options: String,
value: String,
at: Instant,
}

#[derive(Default)]
struct Cache(HashMap<String, Entry>);

impl Cache {
fn get(&self, account: &str, options: &str, now: Instant) -> Option<String> {
self.0
.get(account)
.filter(|entry| entry.options == options && now.duration_since(entry.at) < TTL)
.map(|entry| entry.value.clone())
}

fn put(&mut self, account: &str, options: &str, value: &str, now: Instant) {
self.0.retain(|_, entry| now.duration_since(entry.at) < TTL);
if serde_json::from_str::<serde_json::Value>(value)
.ok()
.is_some_and(|value| value["status"] == "ok")
{
self.0.insert(
account.into(),
Entry {
options: options.into(),
value: value.into(),
at: now,
},
);
} else {
self.0.remove(account);
}
}
}

pub(crate) fn query(account: &str, options: &str, reuse: bool, fetch: impl FnOnce() -> String) -> String {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(Cache::default()));
if reuse {
if let Some(value) = cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(account, options, Instant::now())
{
return value;
}
}
// Never hold the lock while starting a subprocess; other accounts can scan independently.
let value = fetch();
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.put(account, options, &value, Instant::now());
value
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn expires_and_isolates_accounts_and_query_ranges() {
let mut cache = Cache::default();
let now = Instant::now();
let ok = r#"{"status":"ok","data":{"daily":[]}}"#;
cache.put("claude-work", "today", ok, now);
assert_eq!(cache.get("claude-work", "today", now).as_deref(), Some(ok));
assert!(cache.get("claude-personal", "today", now).is_none());
assert!(cache.get("claude-work", "tomorrow", now).is_none());
assert!(cache.get("claude-work", "today", now + TTL).is_none());
cache.put("claude-work", "today", r#"{"status":"runner_failed"}"#, now);
assert!(cache.get("claude-work", "today", now).is_none());
}

#[test]
fn smart_reuses_but_manual_refresh_replaces_the_cache() {
let old = r#"{"status":"ok","data":{"daily":[1]}}"#;
let new = r#"{"status":"ok","data":{"daily":[2]}}"#;
assert_eq!(query("cache-test", "opts", true, || old.into()), old);
assert_eq!(query("cache-test", "opts", true, || panic!("must reuse")), old);
assert_eq!(query("cache-test", "opts", false, || new.into()), new);
assert_eq!(
query("cache-test", "opts", true, || panic!("must reuse latest")),
new
);
}
}
78 changes: 58 additions & 20 deletions src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ pub(crate) fn inject_host_api<'js>(
app_version,
ProbeDeadline::none(),
None,
false,
)
}

Expand All @@ -603,6 +604,7 @@ pub(crate) fn inject_host_api_with_deadline<'js>(
app_version: &str,
deadline: ProbeDeadline,
account: Option<&crate::plugin_engine::manifest::AccountBinding>,
cache_local_usage: bool,
) -> rquickjs::Result<()> {
let globals = ctx.globals();
let probe_ctx = Object::new(ctx.clone())?;
Expand Down Expand Up @@ -648,7 +650,9 @@ pub(crate) fn inject_host_api_with_deadline<'js>(
// An account copy reads the same kind of local logs as its base plugin (from its own home). A
// WSL copy reads none: its logs live in WSL, far too slow to scan over \\wsl.localhost.
let ccusage_id = account.map_or(plugin_id, |account| account.base_id.as_str());
inject_ccusage(ctx, &host, ccusage_id, deadline, home.is_none())?;
inject_ccusage(
ctx, &host, ccusage_id, deadline, home.is_none(), plugin_id, cache_local_usage,
)?;

probe_ctx.set("host", host)?;
globals.set("__openusage_ctx", probe_ctx)?;
Expand Down Expand Up @@ -2678,9 +2682,12 @@ fn inject_ccusage<'js>(
plugin_id: &str,
deadline: ProbeDeadline,
enabled: bool,
cache_id: &str,
reuse: bool,
) -> rquickjs::Result<()> {
let ccusage_obj = Object::new(ctx.clone())?;
let pid = plugin_id.to_string();
let cache_id = cache_id.to_string();

ccusage_obj.set(
"_queryRaw",
Expand All @@ -2697,25 +2704,27 @@ fn inject_ccusage<'js>(
CcusageQueryOpts::default()
}
};
let provider = resolve_ccusage_provider(&opts, &pid);
let Some(_active_query) =
CcusageQueryGuard::acquire(provider, ccusage_home_override(&opts, provider))
else {
log::warn!("[plugin:{}] ccusage query already running", pid);
return Ok(serde_json::json!({ "status": "runner_failed" }).to_string());
};
let runners = collect_ccusage_runners();
Ok(run_ccusage_query_with_runners(
runners,
&opts,
provider,
&pid,
|kind, program, opts, provider, plugin_id| {
run_ccusage_with_runner_deadline(
kind, program, opts, provider, plugin_id, deadline,
)
},
))
Ok(super::ccusage_cache::query(&cache_id, &opts_json, reuse, || {
let provider = resolve_ccusage_provider(&opts, &pid);
let Some(_active_query) =
CcusageQueryGuard::acquire(provider, ccusage_home_override(&opts, provider))
else {
log::warn!("[plugin:{}] ccusage query already running", pid);
return serde_json::json!({ "status": "runner_failed" }).to_string();
};
let runners = collect_ccusage_runners();
run_ccusage_query_with_runners(
runners,
&opts,
provider,
&pid,
|kind, program, opts, provider, plugin_id| {
run_ccusage_with_runner_deadline(
kind, program, opts, provider, plugin_id, deadline,
)
},
)
}))
},
)?,
)?;
Expand Down Expand Up @@ -3491,6 +3500,7 @@ mod tests {
"0.0.0",
ProbeDeadline::none(),
Some(&account),
false,
)
.expect("inject host api");
let home: Option<String> = ctx
Expand Down Expand Up @@ -5272,6 +5282,7 @@ wait
"0.0.0",
ProbeDeadline::none(),
Some(&account),
false,
)
.expect("inject host api");
patch_ccusage_wrapper(&ctx).expect("ccusage wrapper");
Expand All @@ -5285,4 +5296,31 @@ wait
assert_eq!(status, "no_runner");
});
}

#[test]
fn smart_probe_reads_cached_history_without_starting_a_runner() {
let account = "smart-cache-host-test";
super::super::ccusage_cache::query(account, "{}", false, || {
r#"{"status":"ok","data":{"daily":[]}}"#.to_string()
});
let rt = Runtime::new().expect("runtime");
let ctx = Context::full(&rt).expect("context");
ctx.with(|ctx| {
inject_host_api_with_deadline(
&ctx,
account,
&std::env::temp_dir(),
"0.0.0",
ProbeDeadline::none(),
None,
true,
)
.expect("inject host api");
patch_ccusage_wrapper(&ctx).expect("ccusage wrapper");
let status: String = ctx
.eval(r#"__openusage_ctx.host.ccusage.query({}).status"#)
.expect("cached ccusage");
assert_eq!(status, "ok");
});
}
}
1 change: 1 addition & 0 deletions src-tauri/src/plugin_engine/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod host_api;
mod ccusage_cache;
mod http_gate;
pub mod manifest;
pub mod runtime;
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/plugin_engine/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,21 @@ pub struct PluginOutput {
}

pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str) -> PluginOutput {
run_probe_with_cache(plugin, app_data_dir, app_version, false)
}

pub fn run_probe_with_cache(
plugin: &LoadedPlugin,
app_data_dir: &PathBuf,
app_version: &str,
cache_local_usage: bool,
) -> PluginOutput {
run_probe_with_timeout(
plugin,
app_data_dir,
app_version,
Duration::from_secs(PROBE_TIMEOUT_SECS),
cache_local_usage,
)
}

Expand Down Expand Up @@ -112,6 +122,7 @@ pub fn run_account_key(
app_version,
host_api::ProbeDeadline::at(deadline_at),
plugin.account.as_ref(),
false,
)
.ok()?;
host_api::inject_utils(&ctx).ok()?;
Expand All @@ -138,6 +149,7 @@ fn run_probe_with_timeout(
app_data_dir: &PathBuf,
app_version: &str,
timeout: Duration,
cache_local_usage: bool,
) -> PluginOutput {
let fallback = error_output(plugin, "runtime error".to_string());
let timeout_message = probe_timeout_message(timeout);
Expand Down Expand Up @@ -171,6 +183,7 @@ fn run_probe_with_timeout(
app_version,
deadline,
plugin.account.as_ref(),
cache_local_usage,
)
.is_err()
{
Expand Down Expand Up @@ -869,6 +882,7 @@ mod tests {
&temp_app_dir("timeout"),
"0.0.0",
Duration::from_millis(5),
false,
);

assert_eq!(error_text(output), "probe timed out after 5ms");
Expand Down
4 changes: 2 additions & 2 deletions src/components/app/app-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useAppPreferencesStore } from "@/stores/app-preferences-store"
import { useAppUiStore } from "@/stores/app-ui-store"
import type { AccountsChange } from "@/lib/accounts"
import type {
AutoUpdateIntervalMinutes,
AutoUpdateInterval,
DisplayMode,
GlobalShortcut,
MenubarIconStyle,
Expand All @@ -29,7 +29,7 @@ export type AppContentActionProps = {
onReorder: (orderedIds: string[]) => void
onToggle: (id: string) => void
onAccountsChanged: (change: AccountsChange) => void
onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void
onAutoUpdateIntervalChange: (value: AutoUpdateInterval) => void
onThemeModeChange: (mode: ThemeMode) => void
onDisplayModeChange: (mode: DisplayMode) => void
onResetTimerDisplayModeChange: (mode: ResetTimerDisplayMode) => void
Expand Down
1 change: 1 addition & 0 deletions src/components/app/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function AppShell({
/>
</div>
<PanelFooter
refreshing={displayPlugins.some((plugin) => plugin.loading)}
version={appVersion}
autoUpdateNextAt={autoUpdateNextAt}
updateStatus={updateStatus}
Expand Down
8 changes: 8 additions & 0 deletions src/components/panel-footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const noop = () => {}
const footerProps = { showAbout: false, onShowAbout: noop, onCloseAbout: noop, onUpdateCheck: noop }

describe("PanelFooter", () => {
it("shows Refreshing instead of Paused while waiting for checks to complete", () => {
render(
<PanelFooter version="0.0.0" autoUpdateNextAt={null} refreshing updateStatus={idle} onUpdateInstall={noop} {...footerProps} />
)
expect(screen.getByText("Refreshing…")).toBeInTheDocument()
expect(screen.queryByText("Paused")).not.toBeInTheDocument()
})

it("shortens the countdown to just the time while an update is shown, keeping the full text in the tooltip", () => {
render(
<PanelFooter
Expand Down
Loading
Loading