Replace cap-based estimation with PTY-driven 'claude /usage' parser
The widget now spawns 'claude' via portable-pty, sends /usage, parses the three rendered bars (Current session / Current week all / Current week Sonnet), and shows the real percentages in the ring + weekly bars. A background task refreshes every 5 minutes; the title-bar refresh button forces an immediate fetch. Drops the cap-tuning UI and tier card from Settings; adds a 'claude command' override (e.g. 'wsl.exe -- claude' for Windows-host widgets reading WSL credentials) and a refresh-interval setting. Fixes title-bar buttons getting swallowed as drag attempts via data-tauri-drag-region="false".
This commit is contained in:
parent
18e55cd139
commit
db9a10a4c2
13 changed files with 656 additions and 166 deletions
|
|
@ -35,6 +35,8 @@ once_cell = "1"
|
|||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
portable-pty = "0.8"
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_System_Console"] }
|
||||
|
|
|
|||
339
src-tauri/src/cli_usage.rs
Normal file
339
src-tauri/src/cli_usage.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
//! Drive `claude /usage` via a pseudo-TTY and parse the rendered output
|
||||
//! into structured percentages.
|
||||
//!
|
||||
//! This is the only way to get the *real* subscription-side numbers (the
|
||||
//! same ones the user sees when typing `/usage` interactively). Anthropic
|
||||
//! doesn't expose `/usage` via `claude --print`, and the percentages aren't
|
||||
//! cached anywhere on disk between invocations.
|
||||
//!
|
||||
//! Output we parse (whitespace-collapsed, ANSI stripped):
|
||||
//!
|
||||
//! Current session
|
||||
//! ████████████▌ 67% used
|
||||
//! Resets 2:50am (Europe/London)
|
||||
//!
|
||||
//! Current week (all models)
|
||||
//! ████ 8% used
|
||||
//! Resets May 9, 12pm (Europe/London)
|
||||
//!
|
||||
//! Current week (Sonnet only)
|
||||
//! ██▌ 5% used
|
||||
//! Resets May 9, 12pm (Europe/London)
|
||||
//!
|
||||
//! The "Sonnet only" section is not always present (depends on plan tier
|
||||
//! and recent usage). We treat it as optional.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use serde::Serialize;
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UsageBar {
|
||||
/// e.g. "Current session", "Current week (all models)"
|
||||
pub label: String,
|
||||
/// 0..=100
|
||||
pub percent: u8,
|
||||
/// Free text describing reset, e.g. "2:50am (Europe/London)" or
|
||||
/// "May 9, 12pm (Europe/London)". Anthropic's renderer is the source
|
||||
/// of truth here; we don't try to re-format.
|
||||
pub resets_at_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CliUsage {
|
||||
pub session: Option<UsageBar>,
|
||||
pub week_all: Option<UsageBar>,
|
||||
pub week_sonnet: Option<UsageBar>,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
/// True if at least one bar parsed. False on parse failure / timeout
|
||||
/// (UI can fall back to JSONL-derived numbers).
|
||||
pub ok: bool,
|
||||
/// Raw stripped text, kept for diagnostics in Settings.
|
||||
pub raw_text: String,
|
||||
}
|
||||
|
||||
/// Spawn `claude`, send `/usage`, capture output, kill, parse.
|
||||
///
|
||||
/// `command_override` lets the caller supply an alternate program (e.g.
|
||||
/// `wsl.exe` with args) for the Windows-host case. None means just `claude`.
|
||||
pub fn fetch_blocking(command_override: Option<&str>) -> Result<CliUsage> {
|
||||
let raw = drive_claude_usage(command_override, Duration::from_secs(20))?;
|
||||
let stripped = strip_ansi_collapse(&raw);
|
||||
parse_usage_text(&stripped, raw)
|
||||
}
|
||||
|
||||
/// Spawn the CLI in a PTY, send `/usage`, capture stdout for `total_timeout`,
|
||||
/// then send `/exit` and return raw bytes (still containing ANSI escapes).
|
||||
fn drive_claude_usage(command_override: Option<&str>, total_timeout: Duration) -> Result<Vec<u8>> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 50,
|
||||
cols: 200, // wide so /usage doesn't wrap awkwardly
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.context("openpty")?;
|
||||
|
||||
let mut cmd = match command_override {
|
||||
Some(s) => {
|
||||
// Allow simple "wsl.exe -- claude" style strings.
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
return Err(anyhow!("empty command_override"));
|
||||
}
|
||||
let mut c = CommandBuilder::new(parts[0]);
|
||||
for arg in &parts[1..] {
|
||||
c.arg(arg);
|
||||
}
|
||||
c
|
||||
}
|
||||
None => CommandBuilder::new("claude"),
|
||||
};
|
||||
// claude inspects $TERM; give it something reasonable.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
|
||||
let mut child = pair.slave.spawn_command(cmd).context("spawn claude")?;
|
||||
drop(pair.slave); // close our copy of the slave
|
||||
|
||||
let mut writer = pair.master.take_writer().context("take_writer")?;
|
||||
let mut reader = pair.master.try_clone_reader().context("clone_reader")?;
|
||||
|
||||
// Read in a background thread; communicate via a channel.
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let reader_handle = std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut output: Vec<u8> = Vec::with_capacity(64 * 1024);
|
||||
|
||||
// 1. Wait ~3.5s for the TUI to initialize (welcome banner, prompt, etc).
|
||||
drain(&rx, &mut output, Duration::from_millis(3500));
|
||||
|
||||
// 2. Send /usage.
|
||||
writer.write_all(b"/usage\r").context("write /usage")?;
|
||||
writer.flush().ok();
|
||||
|
||||
// 3. Drain output until quiet period or total deadline.
|
||||
let deadline = Instant::now() + total_timeout;
|
||||
let mut last_growth = Instant::now();
|
||||
let prev_len = output.len();
|
||||
let _ = prev_len;
|
||||
loop {
|
||||
let pre = output.len();
|
||||
drain(&rx, &mut output, Duration::from_millis(700));
|
||||
if output.len() > pre {
|
||||
last_growth = Instant::now();
|
||||
}
|
||||
// Quiet for >1.2s → assume render finished.
|
||||
if last_growth.elapsed() > Duration::from_millis(1200) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Best-effort exit. If it doesn't shut down, we'll kill the child.
|
||||
let _ = writer.write_all(b"/exit\r");
|
||||
let _ = writer.flush();
|
||||
drain(&rx, &mut output, Duration::from_millis(500));
|
||||
|
||||
drop(writer);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
drop(pair.master);
|
||||
let _ = reader_handle.join();
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn drain(
|
||||
rx: &std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
out: &mut Vec<u8>,
|
||||
timeout: Duration,
|
||||
) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok(chunk) => out.extend_from_slice(&chunk),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip CSI / OSC / DCS escape sequences and collapse runs of identical
|
||||
/// re-rendered lines (TUIs paint the same content many times during scan).
|
||||
pub fn strip_ansi_collapse(raw: &[u8]) -> String {
|
||||
// CSI: ESC [ ... <terminator>
|
||||
// OSC: ESC ] ... BEL
|
||||
// DCS / SOS / PM / APC: ESC P / X / ^ / _ ... ESC \
|
||||
static CSI_RE: once_cell::sync::Lazy<regex::bytes::Regex> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
regex::bytes::Regex::new(
|
||||
r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\x1b[PX^_].*?\x1b\\",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let cleaned = CSI_RE.replace_all(raw, &b""[..]);
|
||||
// Drop stray BEL / lone ESC.
|
||||
let cleaned: Vec<u8> = cleaned
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&b| b != 0x07 && b != 0x1b)
|
||||
.collect();
|
||||
let text = String::from_utf8_lossy(&cleaned).to_string();
|
||||
|
||||
// Collapse repeated lines (TUI redraws same line hundreds of times).
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim_end();
|
||||
if out.last().map(|p| *p == trimmed).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
out.push(trimmed);
|
||||
}
|
||||
// Keep blank lines for parser hints, but collapse runs of >1 blank.
|
||||
let mut compressed: Vec<&str> = Vec::with_capacity(out.len());
|
||||
let mut prev_blank = false;
|
||||
for line in out {
|
||||
let blank = line.is_empty();
|
||||
if blank && prev_blank {
|
||||
continue;
|
||||
}
|
||||
compressed.push(line);
|
||||
prev_blank = blank;
|
||||
}
|
||||
compressed.join("\n")
|
||||
}
|
||||
|
||||
/// Parse the cleaned text into a [`CliUsage`].
|
||||
///
|
||||
/// Strategy: scan for the headings ("Current session", "Current week (all models)",
|
||||
/// "Current week (Sonnet only)") then look for `NN% used` and `Resets …` on the
|
||||
/// next few lines after each heading.
|
||||
pub fn parse_usage_text(stripped: &str, raw_for_diag: Vec<u8>) -> Result<CliUsage> {
|
||||
let lines: Vec<&str> = stripped.lines().collect();
|
||||
|
||||
let find_section = |label: &str| -> Option<UsageBar> {
|
||||
let idx = lines.iter().position(|l| {
|
||||
let t = l.trim();
|
||||
// Some renderings squash spaces ("Currentsession"). Match both.
|
||||
t == label || t.replace(' ', "") == label.replace(' ', "")
|
||||
})?;
|
||||
// Look at the next 6 lines for "X% used" and "Resets ...".
|
||||
let mut percent: Option<u8> = None;
|
||||
let mut resets: Option<String> = None;
|
||||
for line in &lines[idx + 1..(idx + 7).min(lines.len())] {
|
||||
if percent.is_none() {
|
||||
if let Some(cap) = PCT_RE.captures(line) {
|
||||
if let Ok(n) = cap[1].parse::<u8>() {
|
||||
percent = Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if resets.is_none() {
|
||||
if let Some(cap) = RESET_RE.captures(line) {
|
||||
resets = Some(cap[1].trim().to_string());
|
||||
}
|
||||
}
|
||||
if percent.is_some() && resets.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(UsageBar {
|
||||
label: label.to_string(),
|
||||
percent: percent?,
|
||||
resets_at_text: resets.unwrap_or_default(),
|
||||
})
|
||||
};
|
||||
|
||||
let session = find_section("Current session");
|
||||
let week_all = find_section("Current week (all models)");
|
||||
let week_sonnet = find_section("Current week (Sonnet only)");
|
||||
|
||||
let ok = session.is_some() || week_all.is_some();
|
||||
let _ = raw_for_diag; // accepted for API compat; we surface the cleaned form
|
||||
|
||||
Ok(CliUsage {
|
||||
session,
|
||||
week_all,
|
||||
week_sonnet,
|
||||
fetched_at: Utc::now(),
|
||||
ok,
|
||||
raw_text: stripped.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
static PCT_RE: once_cell::sync::Lazy<regex::Regex> =
|
||||
once_cell::sync::Lazy::new(|| regex::Regex::new(r"(\d{1,3})\s*%\s*used").unwrap());
|
||||
|
||||
static RESET_RE: once_cell::sync::Lazy<regex::Regex> =
|
||||
once_cell::sync::Lazy::new(|| regex::Regex::new(r"Resets?\s+(.+)$").unwrap());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r"Settings Status Config Usage Stats
|
||||
|
||||
Session
|
||||
|
||||
Total cost: $0.0000
|
||||
Total duration (API): 0s
|
||||
Total duration (wall): 4s
|
||||
|
||||
Current session
|
||||
█████████████████████████████████▌ 67% used
|
||||
Resets 2:50am (Europe/London)
|
||||
|
||||
Current week (all models)
|
||||
████ 8% used
|
||||
Resets May 9, 12pm (Europe/London)
|
||||
|
||||
Current week (Sonnet only)
|
||||
██▌ 5% used
|
||||
Resets May 9, 12pm (Europe/London)
|
||||
|
||||
What's contributing to your limits usage?
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parses_three_bars() {
|
||||
let u = parse_usage_text(SAMPLE, Vec::new()).unwrap();
|
||||
assert!(u.ok);
|
||||
let s = u.session.expect("session");
|
||||
assert_eq!(s.percent, 67);
|
||||
assert!(s.resets_at_text.contains("2:50am"));
|
||||
let w = u.week_all.expect("week_all");
|
||||
assert_eq!(w.percent, 8);
|
||||
assert!(w.resets_at_text.contains("May 9"));
|
||||
let so = u.week_sonnet.expect("week_sonnet");
|
||||
assert_eq!(so.percent, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_tolerates_missing_sonnet_section() {
|
||||
let s = SAMPLE
|
||||
.split("Current week (Sonnet only)")
|
||||
.next()
|
||||
.unwrap();
|
||||
let u = parse_usage_text(s, Vec::new()).unwrap();
|
||||
assert!(u.session.is_some());
|
||||
assert!(u.week_all.is_some());
|
||||
assert!(u.week_sonnet.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots};
|
||||
use crate::settings::{save as save_settings, Caps, Settings};
|
||||
use crate::state::SharedState;
|
||||
|
|
@ -74,6 +76,35 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_cli_usage(
|
||||
state: tauri::State<'_, SharedState>,
|
||||
) -> Result<Option<crate::cli_usage::CliUsage>, String> {
|
||||
Ok(state.cli_usage.read().clone())
|
||||
}
|
||||
|
||||
/// Force-refresh /usage by spawning the CLI now. Slow (~3-5s); use sparingly.
|
||||
#[tauri::command]
|
||||
pub async fn refresh_cli_usage(
|
||||
state: tauri::State<'_, SharedState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<crate::cli_usage::CliUsage, String> {
|
||||
let cmd = state.settings.read().claude_command.clone();
|
||||
// Clone the Arc out of `state` (which borrows from the request); the
|
||||
// blocking task needs an owned handle.
|
||||
let shared: SharedState = state.inner().clone();
|
||||
let app_clone = app.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::cli_usage::fetch_blocking(cmd.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("join: {e}"))?
|
||||
.map_err(|e| e.to_string())?;
|
||||
*shared.cli_usage.write() = Some(result.clone());
|
||||
let _ = app_clone.emit("cli-usage-updated", &result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TierInfo {
|
||||
/// The classified tier — `Pro`, `Max5x`, `Max20x`, etc.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//!
|
||||
//! Wires Tauri builder, plugins, app state, and the watcher.
|
||||
|
||||
mod cli_usage;
|
||||
mod commands;
|
||||
mod jsonl;
|
||||
mod paths;
|
||||
|
|
@ -11,7 +12,35 @@ mod tier;
|
|||
mod usage;
|
||||
mod watch;
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use std::time::Duration;
|
||||
|
||||
async fn refresh_cli_loop(app: tauri::AppHandle, state: state::SharedState) {
|
||||
// Small initial delay so we don't compete with cold-start file scanning.
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
loop {
|
||||
let cmd = state.settings.read().claude_command.clone();
|
||||
let interval_secs = state.settings.read().cli_refresh_secs.max(60);
|
||||
|
||||
let cmd_for_blocking = cmd.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
cli_usage::fetch_blocking(cmd_for_blocking.as_deref())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(u)) => {
|
||||
*state.cli_usage.write() = Some(u.clone());
|
||||
let _ = app.emit("cli-usage-updated", &u);
|
||||
}
|
||||
Ok(Err(e)) => tracing::warn!("/usage fetch failed: {e:#}"),
|
||||
Err(e) => tracing::warn!("/usage join failed: {e:#}"),
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Logs go to stderr; in release on Windows there's no console attached,
|
||||
|
|
@ -57,6 +86,13 @@ pub fn run() {
|
|||
}
|
||||
});
|
||||
|
||||
// Periodic /usage refresh.
|
||||
let handle_for_cli = app.handle().clone();
|
||||
let state_for_cli = shared.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
refresh_cli_loop(handle_for_cli, state_for_cli).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
|
@ -68,6 +104,8 @@ pub fn run() {
|
|||
commands::force_rescan,
|
||||
commands::quit_app,
|
||||
commands::detect_plan_tier,
|
||||
commands::get_cli_usage,
|
||||
commands::refresh_cli_usage,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ pub struct Settings {
|
|||
pub window_pos: Option<(i32, i32)>,
|
||||
/// Mirror of the Tauri autostart plugin state for UI display.
|
||||
pub autostart: bool,
|
||||
/// Override for the `claude` invocation used by the PTY-driven /usage
|
||||
/// fetcher. None = auto: try `claude` first, then `wsl.exe -- claude`
|
||||
/// on Windows. Whitespace-separated tokens, e.g. `wsl.exe -- claude`.
|
||||
pub claude_command: Option<String>,
|
||||
/// How often (seconds) to refetch `/usage`. Defaults to 300 (5 min).
|
||||
pub cli_refresh_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -46,6 +52,8 @@ impl Default for Settings {
|
|||
include_native: true,
|
||||
window_pos: None,
|
||||
autostart: false,
|
||||
claude_command: None,
|
||||
cli_refresh_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::cli_usage::CliUsage;
|
||||
use crate::jsonl::UsageEvent;
|
||||
use crate::settings::Settings;
|
||||
use crate::watch::WatcherHandle;
|
||||
|
|
@ -23,6 +24,9 @@ pub struct AppState {
|
|||
pub files: RwLock<HashMap<PathBuf, FileCache>>,
|
||||
pub seen_ids: RwLock<HashSet<String>>,
|
||||
pub settings: RwLock<Settings>,
|
||||
/// Latest /usage result. Refreshed periodically by a background task,
|
||||
/// or on-demand via the `refresh_cli_usage` command.
|
||||
pub cli_usage: RwLock<Option<CliUsage>>,
|
||||
/// Boxed so we can keep the watcher alive across the whole app lifetime
|
||||
/// without polluting Tauri's setup hook.
|
||||
pub watcher: Mutex<Option<WatcherHandle>>,
|
||||
|
|
@ -37,6 +41,7 @@ impl AppState {
|
|||
files: RwLock::new(HashMap::new()),
|
||||
seen_ids: RwLock::new(HashSet::new()),
|
||||
settings: RwLock::new(settings),
|
||||
cli_usage: RwLock::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { UsageSnapshot } from "../types";
|
||||
import { getSnapshot, onUsageUpdated } from "../ipc";
|
||||
import type { CliUsage, UsageSnapshot } from "../types";
|
||||
import {
|
||||
getSnapshot,
|
||||
onUsageUpdated,
|
||||
getCliUsage,
|
||||
onCliUsageUpdated,
|
||||
refreshCliUsage,
|
||||
} from "../ipc";
|
||||
import TitleBar from "./TitleBar.svelte";
|
||||
import BlockRing from "./BlockRing.svelte";
|
||||
import ModelStack from "./ModelStack.svelte";
|
||||
|
|
@ -9,50 +15,75 @@
|
|||
import Settings from "./Settings.svelte";
|
||||
|
||||
let snap = $state<UsageSnapshot | null>(null);
|
||||
let cliUsage = $state<CliUsage | null>(null);
|
||||
let cliRefreshing = $state(false);
|
||||
let showSettings = $state(false);
|
||||
let unlisten: (() => void) | null = null;
|
||||
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Locally-decremented countdown so we don't IPC just to tick the clock.
|
||||
let nowSeconds = $state(0);
|
||||
|
||||
function updateFromSnapshot(s: UsageSnapshot) {
|
||||
snap = s;
|
||||
nowSeconds = s.block?.seconds_remaining ?? 0;
|
||||
}
|
||||
let unlisten1: (() => void) | null = null;
|
||||
let unlisten2: (() => void) | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
updateFromSnapshot(await getSnapshot());
|
||||
snap = await getSnapshot();
|
||||
} catch (e) {
|
||||
console.error("initial snapshot failed", e);
|
||||
}
|
||||
try {
|
||||
unlisten = await onUsageUpdated(updateFromSnapshot);
|
||||
cliUsage = await getCliUsage();
|
||||
} catch (e) {
|
||||
console.error("initial cli usage failed", e);
|
||||
}
|
||||
try {
|
||||
unlisten1 = await onUsageUpdated((s) => (snap = s));
|
||||
unlisten2 = await onCliUsageUpdated((u) => (cliUsage = u));
|
||||
} catch (e) {
|
||||
console.error("listen failed", e);
|
||||
}
|
||||
tickHandle = setInterval(() => {
|
||||
if (nowSeconds > 0) nowSeconds -= 1;
|
||||
}, 1000);
|
||||
|
||||
// If we have nothing yet, fire a one-shot refresh so the widget is
|
||||
// useful right away rather than waiting for the 5-min loop.
|
||||
if (!cliUsage) {
|
||||
void triggerRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
if (tickHandle) clearInterval(tickHandle);
|
||||
unlisten1?.();
|
||||
unlisten2?.();
|
||||
});
|
||||
|
||||
async function triggerRefresh() {
|
||||
if (cliRefreshing) return;
|
||||
cliRefreshing = true;
|
||||
try {
|
||||
cliUsage = await refreshCliUsage();
|
||||
} catch (e) {
|
||||
console.error("refresh /usage failed", e);
|
||||
} finally {
|
||||
cliRefreshing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<TitleBar onSettings={() => (showSettings = true)} />
|
||||
<TitleBar
|
||||
onSettings={() => (showSettings = true)}
|
||||
onRefreshUsage={triggerRefresh}
|
||||
refreshing={cliRefreshing}
|
||||
/>
|
||||
|
||||
{#if snap}
|
||||
<BlockRing block={snap.block} cap={snap.caps.block_tokens} {nowSeconds} />
|
||||
<ModelStack
|
||||
breakdown={snap.block?.by_family ?? snap.weekly.by_family}
|
||||
/>
|
||||
<WeeklyBar weekly={snap.weekly} cap={snap.caps.weekly_tokens} />
|
||||
{:else}
|
||||
<div class="loading">Loading transcripts…</div>
|
||||
<BlockRing bar={cliUsage?.session ?? null} />
|
||||
|
||||
<ModelStack
|
||||
breakdown={snap?.block?.by_family ?? snap?.weekly.by_family ?? { opus: 0, sonnet: 0, haiku: 0, other: 0 }}
|
||||
/>
|
||||
|
||||
<WeeklyBar
|
||||
weekAll={cliUsage?.week_all ?? null}
|
||||
weekSonnet={cliUsage?.week_sonnet ?? null}
|
||||
/>
|
||||
|
||||
{#if cliRefreshing && !cliUsage}
|
||||
<div class="loading">Reading /usage…</div>
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
<script lang="ts">
|
||||
import type { BlockSummary } from "../types";
|
||||
import { formatTokens, formatCountdown, formatPct } from "../format";
|
||||
import type { UsageBar } from "../types";
|
||||
|
||||
// Shows the *real* percentage from `claude /usage` — the
|
||||
// "Current session" bar (5-hour rolling window).
|
||||
let {
|
||||
block,
|
||||
cap,
|
||||
nowSeconds,
|
||||
bar,
|
||||
fallbackText = "—",
|
||||
}: {
|
||||
block: BlockSummary | null;
|
||||
cap: number;
|
||||
/** Locally-decremented countdown so we don't IPC just to tick the clock. */
|
||||
nowSeconds: number;
|
||||
bar: UsageBar | null;
|
||||
fallbackText?: string;
|
||||
} = $props();
|
||||
|
||||
// Geometry.
|
||||
|
|
@ -19,7 +17,7 @@
|
|||
const R = (SIZE - STROKE) / 2;
|
||||
const C = 2 * Math.PI * R;
|
||||
|
||||
let pct = $derived(block && cap > 0 ? Math.min(1, block.total_tokens / cap) : 0);
|
||||
let pct = $derived(bar ? Math.min(1, bar.percent / 100) : 0);
|
||||
let dash = $derived(C * pct);
|
||||
let color = $derived(
|
||||
pct > 0.95 ? "var(--danger)" : pct > 0.8 ? "var(--warn)" : "var(--accent)",
|
||||
|
|
@ -29,7 +27,6 @@
|
|||
|
||||
<div class="wrap">
|
||||
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
|
||||
<!-- track -->
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
|
|
@ -38,7 +35,6 @@
|
|||
stroke="var(--border)"
|
||||
stroke-width={STROKE}
|
||||
/>
|
||||
<!-- progress -->
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
|
|
@ -52,13 +48,13 @@
|
|||
class:pulse
|
||||
/>
|
||||
<text x={SIZE / 2} y={SIZE / 2 - 6} text-anchor="middle" class="big">
|
||||
{block ? formatTokens(block.total_tokens) : "—"}
|
||||
{bar ? `${bar.percent}%` : fallbackText}
|
||||
</text>
|
||||
<text x={SIZE / 2} y={SIZE / 2 + 12} text-anchor="middle" class="small">
|
||||
{block ? formatPct(block.total_tokens, cap) : ""}
|
||||
{bar ? "session" : ""}
|
||||
</text>
|
||||
<text x={SIZE / 2} y={SIZE / 2 + 30} text-anchor="middle" class="countdown">
|
||||
{block ? formatCountdown(nowSeconds) : "no activity"}
|
||||
<text x={SIZE / 2} y={SIZE / 2 + 30} text-anchor="middle" class="resets">
|
||||
{bar ? `resets ${bar.resets_at_text.split('(')[0].trim()}` : "no /usage data"}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -66,9 +62,9 @@
|
|||
<style>
|
||||
.wrap { display: flex; justify-content: center; padding: 14px 0 6px; }
|
||||
text { fill: var(--fg); font-family: inherit; }
|
||||
text.big { font-size: 22px; font-weight: 600; }
|
||||
text.big { font-size: 26px; font-weight: 600; }
|
||||
text.small { font-size: 11px; fill: var(--fg-dim); }
|
||||
text.countdown { font-size: 11px; fill: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
||||
text.resets { font-size: 10px; fill: var(--fg-dim); }
|
||||
.pulse { animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
|
|
|||
|
|
@ -10,17 +10,19 @@
|
|||
setSettings,
|
||||
listDistros,
|
||||
getRoots,
|
||||
detectPlanTier,
|
||||
refreshCliUsage,
|
||||
getCliUsage,
|
||||
} from "../ipc";
|
||||
import type { Settings, ResolvedRoots, TierInfo } from "../types";
|
||||
import type { Settings, ResolvedRoots, CliUsage } from "../types";
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
let settings = $state<Settings | null>(null);
|
||||
let distros = $state<string[]>([]);
|
||||
let roots = $state<ResolvedRoots | null>(null);
|
||||
let tier = $state<TierInfo | null>(null);
|
||||
let cli = $state<CliUsage | null>(null);
|
||||
let busy = $state(false);
|
||||
let testingCli = $state(false);
|
||||
let err = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -28,8 +30,7 @@
|
|||
settings = await getSettings();
|
||||
distros = await listDistros();
|
||||
roots = await getRoots();
|
||||
tier = await detectPlanTier();
|
||||
// Sync displayed autostart from the plugin's actual state.
|
||||
cli = await getCliUsage();
|
||||
const enabled = await isAutostartEnabled();
|
||||
if (settings && settings.autostart !== enabled) {
|
||||
settings = { ...settings, autostart: enabled };
|
||||
|
|
@ -39,23 +40,11 @@
|
|||
}
|
||||
});
|
||||
|
||||
function applyTierCaps() {
|
||||
if (!settings || !tier) return;
|
||||
settings = {
|
||||
...settings,
|
||||
caps: {
|
||||
block_tokens: tier.recommended_caps.block_tokens,
|
||||
weekly_tokens: tier.recommended_caps.weekly_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!settings) return;
|
||||
busy = true;
|
||||
err = null;
|
||||
try {
|
||||
// Autostart state must be applied via the plugin, not just stored.
|
||||
if (settings.autostart) await enableAutostart();
|
||||
else await disableAutostart();
|
||||
await setSettings(settings);
|
||||
|
|
@ -66,6 +55,21 @@
|
|||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testCli() {
|
||||
if (!settings) return;
|
||||
testingCli = true;
|
||||
err = null;
|
||||
try {
|
||||
// Save first so the new claude_command is in effect.
|
||||
await setSettings(settings);
|
||||
cli = await refreshCliUsage();
|
||||
} catch (e) {
|
||||
err = `${e}`;
|
||||
} finally {
|
||||
testingCli = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay">
|
||||
|
|
@ -78,49 +82,37 @@
|
|||
{#if err}<div class="error">{err}</div>{/if}
|
||||
|
||||
{#if settings}
|
||||
{#if tier}
|
||||
<div class="tier-card">
|
||||
<div class="row spread">
|
||||
<div>
|
||||
<div class="label" style="margin:0">Plan tier</div>
|
||||
<div>{tier.label}</div>
|
||||
</div>
|
||||
<button onclick={applyTierCaps} title="Set caps to recommended values for this tier">
|
||||
Use recommended
|
||||
</button>
|
||||
</div>
|
||||
<div class="muted hint">
|
||||
Approximate — Anthropic doesn't publish exact caps. Tune below
|
||||
once you actually hit a limit.
|
||||
</div>
|
||||
{#if tier.label.startsWith("Unknown") && tier.searched.length > 0}
|
||||
<details class="muted hint">
|
||||
<summary>Searched paths</summary>
|
||||
<ul class="paths">
|
||||
{#each tier.searched as p (p)}<li><code>{p}</code></li>{/each}
|
||||
</ul>
|
||||
</details>
|
||||
<div class="field">
|
||||
<div class="label">claude command</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. wsl.exe -- claude (auto if blank)"
|
||||
bind:value={settings.claude_command}
|
||||
/>
|
||||
<div class="muted hint">
|
||||
How the widget invokes Claude Code to read /usage. Leave blank to
|
||||
auto-detect (`claude` first, then `wsl.exe -- claude`).
|
||||
</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<button onclick={testCli} disabled={testingCli || busy}>
|
||||
{testingCli ? "Reading…" : "Test /usage now"}
|
||||
</button>
|
||||
{#if cli}
|
||||
<span class="muted hint">
|
||||
{cli.ok ? "OK" : "no bars parsed"} ·
|
||||
fetched {new Date(cli.fetched_at).toLocaleTimeString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<div class="label">5-hour block cap (tokens)</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100000"
|
||||
bind:value={settings.caps.block_tokens}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="label">Weekly cap (tokens)</div>
|
||||
<div class="label">/usage refresh interval (seconds)</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1000000"
|
||||
bind:value={settings.caps.weekly_tokens}
|
||||
min="60"
|
||||
step="60"
|
||||
bind:value={settings.cli_refresh_secs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -136,7 +128,7 @@
|
|||
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={settings.include_native} />
|
||||
Also scan native <code>%USERPROFILE%\.claude\projects</code>
|
||||
Also scan <code>%USERPROFILE%\.claude\projects</code>
|
||||
</label>
|
||||
|
||||
<label class="check">
|
||||
|
|
@ -192,19 +184,7 @@
|
|||
.actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
|
||||
.roots code { font-size: 10px; word-break: break-all; }
|
||||
.tier-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.hint { font-size: 10px; line-height: 1.3; }
|
||||
details summary { cursor: pointer; font-size: 10px; }
|
||||
ul.paths { margin: 4px 0 0; padding-left: 14px; max-height: 70px; overflow: auto; }
|
||||
ul.paths code { font-size: 10px; word-break: break-all; }
|
||||
.error {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid var(--danger);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { quitApp, forceRescan } from "../ipc";
|
||||
let { onSettings }: { onSettings: () => void } = $props();
|
||||
import { quitApp } from "../ipc";
|
||||
let {
|
||||
onSettings,
|
||||
onRefreshUsage,
|
||||
refreshing = false,
|
||||
}: {
|
||||
onSettings: () => void;
|
||||
onRefreshUsage?: () => void;
|
||||
refreshing?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
data-tauri-drag-region: anywhere with this attribute is grabbable as a
|
||||
window-drag handle. https://v2.tauri.app/learn/window-customization/
|
||||
data-tauri-drag-region on the header makes the title bar draggable. Buttons
|
||||
must explicitly opt out via data-tauri-drag-region="false" or clicks get
|
||||
swallowed as drag attempts.
|
||||
-->
|
||||
<header data-tauri-drag-region>
|
||||
<span class="title" data-tauri-drag-region>Claude Usage</span>
|
||||
<div class="actions">
|
||||
<button class="icon" title="Refresh" onclick={forceRescan} aria-label="Refresh">↻</button>
|
||||
<button class="icon" title="Settings" onclick={onSettings} aria-label="Settings">⚙</button>
|
||||
<button class="icon" title="Quit" onclick={quitApp} aria-label="Quit">×</button>
|
||||
<button
|
||||
class="icon"
|
||||
class:spin={refreshing}
|
||||
data-tauri-drag-region="false"
|
||||
title="Re-fetch /usage"
|
||||
onclick={() => onRefreshUsage?.()}
|
||||
aria-label="Refresh /usage"
|
||||
>↻</button>
|
||||
<button
|
||||
class="icon"
|
||||
data-tauri-drag-region="false"
|
||||
title="Settings"
|
||||
onclick={onSettings}
|
||||
aria-label="Settings"
|
||||
>⚙</button>
|
||||
<button
|
||||
class="icon"
|
||||
data-tauri-drag-region="false"
|
||||
title="Quit"
|
||||
onclick={quitApp}
|
||||
aria-label="Quit"
|
||||
>×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -34,4 +62,9 @@
|
|||
cursor: grab;
|
||||
}
|
||||
.actions { display: inline-flex; gap: 2px; }
|
||||
.icon.spin { animation: spin 1.2s linear infinite; }
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { WeeklySummary } from "../types";
|
||||
import { formatTokens, formatPct, weekdayShort } from "../format";
|
||||
import type { UsageBar } from "../types";
|
||||
|
||||
let {
|
||||
weekly,
|
||||
cap,
|
||||
weekAll,
|
||||
weekSonnet,
|
||||
}: {
|
||||
weekly: WeeklySummary;
|
||||
cap: number;
|
||||
weekAll: UsageBar | null;
|
||||
weekSonnet: UsageBar | null;
|
||||
} = $props();
|
||||
|
||||
let max = $derived(
|
||||
Math.max(1, ...weekly.by_day.map((d) => d.total_tokens))
|
||||
);
|
||||
function pctOrZero(b: UsageBar | null) {
|
||||
return b ? Math.min(100, Math.max(0, b.percent)) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<div class="row spread">
|
||||
<div class="label">7-day total</div>
|
||||
<div class="muted">{formatTokens(weekly.total_tokens)} · {formatPct(weekly.total_tokens, cap)}</div>
|
||||
</div>
|
||||
<div class="bars">
|
||||
{#each weekly.by_day as d (d.date_local)}
|
||||
<div class="col" title="{d.date_local}: {formatTokens(d.total_tokens)}">
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
style="height: {(d.total_tokens / max) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="day-label">{weekdayShort(d.date_local)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="label">Weekly limits</div>
|
||||
{#if weekAll}
|
||||
<div class="muted">resets {weekAll.resets_at_text.split('(')[0].trim()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if weekAll}
|
||||
<div class="bar-row">
|
||||
<span class="lbl">All models</span>
|
||||
<div class="track"><div class="fill" style="width:{pctOrZero(weekAll)}%"></div></div>
|
||||
<span class="num">{weekAll.percent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if weekSonnet}
|
||||
<div class="bar-row">
|
||||
<span class="lbl">Sonnet</span>
|
||||
<div class="track"><div class="fill sonnet" style="width:{pctOrZero(weekSonnet)}%"></div></div>
|
||||
<span class="num">{weekSonnet.percent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !weekAll && !weekSonnet}
|
||||
<div class="muted">no /usage data yet</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bars {
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
height: 50px;
|
||||
align-items: end;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-template-columns: 60px 1fr 36px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: var(--bg-card);
|
||||
.lbl { color: var(--fg-dim); }
|
||||
.num { color: var(--fg); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
min-height: 1px;
|
||||
transition: width 250ms ease-out;
|
||||
}
|
||||
.day-label { font-size: 9px; color: var(--fg-dim); }
|
||||
.fill.sonnet { background: var(--sonnet); }
|
||||
</style>
|
||||
|
|
|
|||
15
src/ipc.ts
15
src/ipc.ts
|
|
@ -1,6 +1,12 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { ResolvedRoots, Settings, TierInfo, UsageSnapshot } from "./types";
|
||||
import type {
|
||||
CliUsage,
|
||||
ResolvedRoots,
|
||||
Settings,
|
||||
TierInfo,
|
||||
UsageSnapshot,
|
||||
} from "./types";
|
||||
|
||||
export const getSnapshot = (): Promise<UsageSnapshot> => invoke("get_snapshot");
|
||||
export const getSettings = (): Promise<Settings> => invoke("get_settings");
|
||||
|
|
@ -11,8 +17,15 @@ export const getRoots = (): Promise<ResolvedRoots> => invoke("get_roots");
|
|||
export const forceRescan = (): Promise<void> => invoke("force_rescan");
|
||||
export const quitApp = (): Promise<void> => invoke("quit_app");
|
||||
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
|
||||
export const getCliUsage = (): Promise<CliUsage | null> => invoke("get_cli_usage");
|
||||
export const refreshCliUsage = (): Promise<CliUsage> => invoke("refresh_cli_usage");
|
||||
|
||||
export const onUsageUpdated = (
|
||||
cb: (snap: UsageSnapshot) => void,
|
||||
): Promise<UnlistenFn> =>
|
||||
listen<UsageSnapshot>("usage-updated", (e) => cb(e.payload));
|
||||
|
||||
export const onCliUsageUpdated = (
|
||||
cb: (u: CliUsage) => void,
|
||||
): Promise<UnlistenFn> =>
|
||||
listen<CliUsage>("cli-usage-updated", (e) => cb(e.payload));
|
||||
|
|
|
|||
16
src/types.ts
16
src/types.ts
|
|
@ -69,3 +69,19 @@ export interface TierInfo {
|
|||
recommended_caps: Caps;
|
||||
searched: string[];
|
||||
}
|
||||
|
||||
// Mirrors src-tauri/src/cli_usage.rs.
|
||||
export interface UsageBar {
|
||||
label: string;
|
||||
percent: number; // 0..=100
|
||||
resets_at_text: string;
|
||||
}
|
||||
|
||||
export interface CliUsage {
|
||||
session: UsageBar | null;
|
||||
week_all: UsageBar | null;
|
||||
week_sonnet: UsageBar | null;
|
||||
fetched_at: string; // ISO 8601
|
||||
ok: boolean;
|
||||
raw_text: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue