This commit is contained in:
2026-05-17 03:53:15 +03:30
commit 329ddc9ced
41 changed files with 5558 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# End of https://www.toptal.com/developers/gitignore/api/rust
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
*.log
.DS_Store
.idea/

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Codex Limit Monitor
Windows-first desktop monitor for Codex usage and ChatGPT Codex rate-limit status.
## What It Does
- Reads live Codex limit percentages from `codex app-server` using `account/rateLimits/read`.
- Falls back to read-only local usage summaries from `%USERPROFILE%\.codex\state_5.sqlite` and `logs_2.sqlite`.
- Shows status in a Tauri dashboard, system tray menu, native notifications, and an always-on-top badge.
## Development
The current PowerShell session has Rust/Cargo available, but plain `node`, `npm`, and `codex` are not on PATH. If needed, use the configured Node/NPM path from Herd/NVM or set the Codex executable path inside the app settings.
```powershell
npm install
npm run tauri dev
```
If `codex` is not on PATH, set the Codex executable in Settings. The app also tries common Windows candidates such as `%APPDATA%\npm\codex.cmd`.
## Verification
```powershell
cargo test --manifest-path src-tauri/Cargo.toml
npm run build
```

12
badge.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="badge-html">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex Badge</title>
</head>
<body class="badge-body">
<div id="badge-root"></div>
<script type="module" src="/src/badge.tsx"></script>
</body>
</html>

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex Limit Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2472
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "codex-limit-monitor",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 1420",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 1420",
"tauri": "tauri",
"test": "vitest run"
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.0",
"vite": "^7.2.0",
"vitest": "^4.0.0"
}
}

1
resume Normal file
View File

@@ -0,0 +1 @@
codex resume 019e2e41-c278-7722-9f72-500a7a88a338

35
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "codex-limit-monitor"
version = "0.1.0"
description = "Windows-first desktop monitor for Codex limits and usage"
authors = ["meghdad"]
edition = "2021"
[lib]
name = "codex_limit_monitor"
crate-type = ["rlib"]
[[bin]]
name = "codex-limit-monitor"
path = "src/main.rs"
test = false
[build-dependencies]
tauri-build = { version = "2.6", features = [] }
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11", features = ["tray-icon", "image-png"] }
tauri-plugin-notification = "2.3"
thiserror = "2"
tokio = { version = "1", features = ["io-util", "macros", "process", "sync", "time"] }
tracing = "0.1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Codex Limit Monitor",
"windows": ["main", "badge"],
"permissions": [
"core:default",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-show"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
src-tauri/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

BIN
src-tauri/icons/icon-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src-tauri/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src-tauri/icons/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

248
src-tauri/src/codex_rpc.rs Normal file
View File

@@ -0,0 +1,248 @@
use crate::{models::RateLimitBucket, settings::AppSettings};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{collections::BTreeMap, process::Stdio, time::Duration};
use thiserror::Error;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{ChildStdout, Command},
time::timeout,
};
const RPC_TIMEOUT: Duration = Duration::from_secs(20);
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[derive(Debug, Error)]
pub enum CodexRpcError {
#[error("failed to start Codex app-server: {0}")]
Spawn(#[from] std::io::Error),
#[error("Codex app-server did not expose stdio")]
MissingPipe,
#[error("Codex app-server timed out")]
Timeout,
#[error("Codex app-server closed stdout")]
Closed,
#[error("invalid app-server JSON: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("app-server returned error: {0}")]
Rpc(String),
#[error("app-server response was missing result")]
MissingResult,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RateLimitsEnvelope {
rate_limits: Option<RawRateLimitBucket>,
rate_limits_by_limit_id: Option<BTreeMap<String, RawRateLimitBucket>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawRateLimitBucket {
limit_id: String,
limit_name: Option<String>,
primary: Option<RawLimitWindow>,
secondary: Option<RawLimitWindow>,
rate_limit_reached_type: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawLimitWindow {
used_percent: Option<f64>,
window_duration_mins: Option<u64>,
resets_at: Option<i64>,
}
pub async fn fetch_rate_limits(
settings: &AppSettings,
) -> Result<Vec<RateLimitBucket>, CodexRpcError> {
let executable = settings.resolve_codex_executable();
let mut command = Command::new(executable);
command
.arg("app-server")
.arg("--listen")
.arg("stdio://")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = command.spawn()?;
let mut stdin = child.stdin.take().ok_or(CodexRpcError::MissingPipe)?;
let stdout = child.stdout.take().ok_or(CodexRpcError::MissingPipe)?;
let mut reader = BufReader::new(stdout).lines();
write_json(
&mut stdin,
json!({
"jsonrpc": "2.0",
"method": "initialize",
"id": 0,
"params": {
"clientInfo": {
"name": "codex_limit_monitor",
"title": "Codex Limit Monitor",
"version": env!("CARGO_PKG_VERSION")
}
}
}),
)
.await?;
read_response(&mut reader, 0).await?;
write_json(
&mut stdin,
json!({
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
}),
)
.await?;
write_json(
&mut stdin,
json!({
"jsonrpc": "2.0",
"method": "account/rateLimits/read",
"id": 1
}),
)
.await?;
let response = read_response(&mut reader, 1).await?;
let _ = child.kill().await;
let result = response
.get("result")
.cloned()
.ok_or(CodexRpcError::MissingResult)?;
parse_rate_limits(result)
}
async fn write_json(
stdin: &mut tokio::process::ChildStdin,
value: Value,
) -> Result<(), CodexRpcError> {
let mut line = serde_json::to_vec(&value)?;
line.push(b'\n');
stdin.write_all(&line).await?;
stdin.flush().await?;
Ok(())
}
async fn read_response(
reader: &mut tokio::io::Lines<BufReader<ChildStdout>>,
id: u64,
) -> Result<Value, CodexRpcError> {
loop {
let line = timeout(RPC_TIMEOUT, reader.next_line())
.await
.map_err(|_| CodexRpcError::Timeout)?
.map_err(CodexRpcError::Spawn)?
.ok_or(CodexRpcError::Closed)?;
let value: Value = serde_json::from_str(&line)?;
if value.get("id").and_then(Value::as_u64) != Some(id) {
continue;
}
if let Some(error) = value.get("error") {
return Err(CodexRpcError::Rpc(error.to_string()));
}
return Ok(value);
}
}
fn parse_rate_limits(result: Value) -> Result<Vec<RateLimitBucket>, CodexRpcError> {
let envelope: RateLimitsEnvelope = serde_json::from_value(result)?;
let mut buckets: Vec<RateLimitBucket> = Vec::new();
if let Some(by_id) = envelope.rate_limits_by_limit_id {
for raw in by_id.into_values() {
buckets.push(raw.into());
}
} else if let Some(raw) = envelope.rate_limits {
buckets.push(raw.into());
}
buckets.sort_by(|a, b| a.limit_id.cmp(&b.limit_id));
Ok(buckets)
}
impl From<RawRateLimitBucket> for RateLimitBucket {
fn from(raw: RawRateLimitBucket) -> Self {
let primary = raw.primary.unwrap_or_default();
let secondary = raw.secondary;
Self {
limit_id: raw.limit_id,
limit_name: raw.limit_name,
used_percent: primary.used_percent.unwrap_or(0.0),
window_duration_mins: primary.window_duration_mins,
resets_at: primary.resets_at,
secondary_used_percent: secondary.as_ref().and_then(|value| value.used_percent),
secondary_resets_at: secondary.and_then(|value| value.resets_at),
reached_type: raw.rate_limit_reached_type,
}
}
}
impl Default for RawLimitWindow {
fn default() -> Self {
Self {
used_percent: Some(0.0),
window_duration_mins: None,
resets_at: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_multi_bucket_rate_limits() {
let value = json!({
"rateLimitsByLimitId": {
"codex": {
"limitId": "codex",
"limitName": null,
"primary": {
"usedPercent": 25,
"windowDurationMins": 15,
"resetsAt": 1730947200
},
"secondary": null,
"rateLimitReachedType": null
},
"codex_other": {
"limitId": "codex_other",
"limitName": "codex_other",
"primary": {
"usedPercent": 42,
"windowDurationMins": 60,
"resetsAt": 1730950800
}
}
}
});
let buckets = parse_rate_limits(value).unwrap();
assert_eq!(buckets.len(), 2);
assert_eq!(buckets[0].limit_id, "codex");
assert_eq!(buckets[0].used_percent, 25.0);
assert_eq!(buckets[1].limit_id, "codex_other");
assert_eq!(buckets[1].window_duration_mins, Some(60));
}
}

201
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,201 @@
pub mod codex_rpc;
pub mod local_usage;
pub mod models;
#[cfg(not(test))]
pub mod monitor;
pub mod settings;
#[cfg(not(test))]
pub mod tray;
#[cfg(not(test))]
use monitor::MonitorService;
#[cfg(not(test))]
use settings::{AppSettings, BadgePosition};
#[cfg(not(test))]
use std::sync::Arc;
#[cfg(not(test))]
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, State};
#[cfg(not(test))]
#[derive(Clone)]
pub struct AppState {
monitor: Arc<MonitorService>,
}
#[cfg(not(test))]
#[tauri::command]
async fn get_snapshot(state: State<'_, AppState>) -> Result<models::MonitorSnapshot, String> {
state.monitor.snapshot_or_refresh(None).await
}
#[cfg(not(test))]
#[tauri::command]
async fn refresh_now(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<models::MonitorSnapshot, String> {
state.monitor.refresh(Some(app)).await
}
#[cfg(not(test))]
#[tauri::command]
async fn get_usage_history(
range: String,
state: State<'_, AppState>,
) -> Result<models::UsageHistory, String> {
let settings = state.monitor.settings().await;
local_usage::read_usage_history(&settings, &range).map_err(|err| err.to_string())
}
#[cfg(not(test))]
#[tauri::command]
async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
Ok(state.monitor.settings().await)
}
#[cfg(not(test))]
#[tauri::command]
async fn update_settings(
app: AppHandle,
settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
settings.save().map_err(|err| err.to_string())?;
state.monitor.replace_settings(settings.clone()).await;
apply_badge_visibility(&app, settings.badge_visible)?;
apply_badge_position(&app, &settings.badge_position)?;
let snapshot = state.monitor.refresh(Some(app.clone())).await?;
let _ = app.emit("settings_updated", &settings);
let _ = app.emit("rate_limit_updated", &snapshot);
Ok(settings)
}
#[cfg(not(test))]
#[tauri::command]
async fn toggle_badge(
app: AppHandle,
visible: bool,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let mut settings = state.monitor.settings().await;
settings.badge_visible = visible;
settings.save().map_err(|err| err.to_string())?;
state.monitor.replace_settings(settings.clone()).await;
apply_badge_visibility(&app, visible)?;
let _ = app.emit("settings_updated", &settings);
Ok(settings)
}
#[cfg(not(test))]
#[tauri::command]
async fn set_badge_position(
app: AppHandle,
position: BadgePosition,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let mut settings = state.monitor.settings().await;
settings.badge_position = position;
settings.save().map_err(|err| err.to_string())?;
state.monitor.replace_settings(settings.clone()).await;
apply_badge_position(&app, &settings.badge_position)?;
let _ = app.emit("settings_updated", &settings);
Ok(settings)
}
#[cfg(not(test))]
#[tauri::command]
async fn move_badge(app: AppHandle, position: BadgePosition) -> Result<(), String> {
apply_badge_position(&app, &position)
}
#[cfg(not(test))]
#[tauri::command]
async fn pause_notifications(
until: Option<i64>,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let mut settings = state.monitor.settings().await;
settings.notifications_muted_until = until;
settings.save().map_err(|err| err.to_string())?;
state.monitor.replace_settings(settings.clone()).await;
Ok(settings)
}
#[cfg(not(test))]
#[tauri::command]
async fn open_dashboard(app: AppHandle) -> Result<(), String> {
show_dashboard(&app)
}
#[cfg(not(test))]
pub(crate) fn show_dashboard(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
window.show().map_err(|err| err.to_string())?;
window.set_focus().map_err(|err| err.to_string())?;
}
Ok(())
}
#[cfg(not(test))]
pub(crate) fn apply_badge_visibility(app: &AppHandle, visible: bool) -> Result<(), String> {
if let Some(window) = app.get_webview_window("badge") {
if visible {
window.show().map_err(|err| err.to_string())?;
} else {
window.hide().map_err(|err| err.to_string())?;
}
}
Ok(())
}
#[cfg(not(test))]
fn apply_badge_position(app: &AppHandle, position: &BadgePosition) -> Result<(), String> {
let Some(window) = app.get_webview_window("badge") else {
return Ok(());
};
window
.set_position(PhysicalPosition::new(position.x, position.y))
.map_err(|err| err.to_string())
}
#[cfg(not(test))]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.setup(|app| {
let settings = AppSettings::load().unwrap_or_default();
let monitor = Arc::new(MonitorService::new(settings.clone()));
app.manage(AppState {
monitor: monitor.clone(),
});
tray::setup_tray(app.handle())?;
apply_badge_visibility(app.handle(), settings.badge_visible)?;
apply_badge_position(app.handle(), &settings.badge_position)?;
if let Some(window) = app.get_webview_window("badge") {
let _ = window.set_shadow(false);
}
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
monitor.run(app_handle).await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_snapshot,
refresh_now,
get_usage_history,
get_settings,
update_settings,
toggle_badge,
set_badge_position,
move_badge,
pause_notifications,
open_dashboard
])
.run(tauri::generate_context!())
.expect("error while running Codex Limit Monitor");
}

View File

@@ -0,0 +1,275 @@
use crate::{
models::{ModelUsage, ThreadUsage, TokenBreakdown, UsageHistory, UsagePoint, UsageSummary},
settings::AppSettings,
};
use chrono::{Local, TimeZone, Utc};
use regex::Regex;
use rusqlite::{Connection, OpenFlags, Row};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LocalUsageError {
#[error("Codex state database was not found at {0}")]
MissingStateDb(String),
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
}
pub fn read_usage_summary(settings: &AppSettings) -> UsageSummary {
match read_usage_summary_inner(settings) {
Ok(summary) => summary,
Err(error) => UsageSummary {
source_path: Some(settings.codex_home.clone()),
error: Some(error.to_string()),
..UsageSummary::default()
},
}
}
pub fn read_usage_history(
settings: &AppSettings,
range: &str,
) -> Result<UsageHistory, LocalUsageError> {
let state_path = state_db_path(settings);
if !state_path.exists() {
return Err(LocalUsageError::MissingStateDb(
state_path.to_string_lossy().to_string(),
));
}
let conn = open_read_only(&state_path)?;
let days = match range {
"24h" => 1,
"30d" => 30,
_ => 7,
};
let since = Utc::now().timestamp() - days * 86_400;
let mut statement = conn.prepare(
"SELECT date(updated_at, 'unixepoch', 'localtime') AS bucket,
COALESCE(SUM(tokens_used), 0) AS tokens,
COUNT(*) AS threads
FROM threads
WHERE updated_at >= ?1
GROUP BY bucket
ORDER BY bucket ASC",
)?;
let points = statement
.query_map([since], |row| {
Ok(UsagePoint {
label: row.get(0)?,
tokens: row.get(1)?,
threads: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(UsageHistory {
range: range.to_string(),
points,
})
}
fn read_usage_summary_inner(settings: &AppSettings) -> Result<UsageSummary, LocalUsageError> {
let state_path = state_db_path(settings);
if !state_path.exists() {
return Err(LocalUsageError::MissingStateDb(
state_path.to_string_lossy().to_string(),
));
}
let conn = open_read_only(&state_path)?;
let today_start = local_midnight_timestamp();
let week_start = Utc::now().timestamp() - 7 * 86_400;
let (total_tokens, thread_count) = sum_tokens(&conn, None)?;
let (today_tokens, today_thread_count) = sum_tokens(&conn, Some(today_start))?;
let (week_tokens, week_thread_count) = sum_tokens(&conn, Some(week_start))?;
let recent_threads = recent_threads(&conn)?;
let current_thread = recent_threads.first().cloned();
let model_breakdown = model_breakdown(&conn)?;
let token_breakdown = read_token_breakdown(settings, week_start).unwrap_or_default();
Ok(UsageSummary {
total_tokens,
today_tokens,
week_tokens,
thread_count,
today_thread_count,
week_thread_count,
current_thread,
recent_threads,
model_breakdown,
token_breakdown,
source_path: Some(state_path.to_string_lossy().to_string()),
error: None,
})
}
fn open_read_only(path: &Path) -> Result<Connection, rusqlite::Error> {
Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
}
fn state_db_path(settings: &AppSettings) -> PathBuf {
settings.codex_home_path().join("state_5.sqlite")
}
fn logs_db_path(settings: &AppSettings) -> PathBuf {
settings.codex_home_path().join("logs_2.sqlite")
}
fn local_midnight_timestamp() -> i64 {
let date = Local::now().date_naive();
let midnight = date.and_hms_opt(0, 0, 0).expect("valid midnight");
Local
.from_local_datetime(&midnight)
.earliest()
.expect("valid local midnight")
.timestamp()
}
fn sum_tokens(conn: &Connection, since: Option<i64>) -> Result<(i64, i64), rusqlite::Error> {
if let Some(since) = since {
conn.query_row(
"SELECT COALESCE(SUM(tokens_used), 0), COUNT(*) FROM threads WHERE updated_at >= ?1",
[since],
|row| Ok((row.get(0)?, row.get(1)?)),
)
} else {
conn.query_row(
"SELECT COALESCE(SUM(tokens_used), 0), COUNT(*) FROM threads",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
}
}
fn recent_threads(conn: &Connection) -> Result<Vec<ThreadUsage>, rusqlite::Error> {
let mut statement = conn.prepare(
"SELECT id, title, tokens_used, model, reasoning_effort, updated_at, cwd
FROM threads
ORDER BY updated_at DESC
LIMIT 12",
)?;
let rows = statement
.query_map([], row_to_thread)?
.collect::<Result<Vec<_>, _>>();
rows
}
fn model_breakdown(conn: &Connection) -> Result<Vec<ModelUsage>, rusqlite::Error> {
let mut statement = conn.prepare(
"SELECT COALESCE(NULLIF(model, ''), 'unknown') AS model,
COALESCE(SUM(tokens_used), 0) AS tokens,
COUNT(*) AS threads
FROM threads
GROUP BY model
ORDER BY tokens DESC
LIMIT 8",
)?;
let rows = statement
.query_map([], |row| {
Ok(ModelUsage {
model: row.get(0)?,
tokens: row.get(1)?,
threads: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>();
rows
}
fn row_to_thread(row: &Row<'_>) -> Result<ThreadUsage, rusqlite::Error> {
Ok(ThreadUsage {
id: row.get(0)?,
title: row
.get::<_, String>(1)
.unwrap_or_else(|_| "Untitled".to_string()),
tokens_used: row.get(2)?,
model: row.get(3)?,
reasoning_effort: row.get(4)?,
updated_at: row.get(5)?,
cwd: row.get(6)?,
})
}
fn read_token_breakdown(
settings: &AppSettings,
since: i64,
) -> Result<TokenBreakdown, LocalUsageError> {
let logs_path = logs_db_path(settings);
if !logs_path.exists() {
return Ok(TokenBreakdown::default());
}
let conn = open_read_only(&logs_path)?;
let mut statement = conn.prepare(
"SELECT feedback_log_body
FROM logs
WHERE ts >= ?1
AND feedback_log_body LIKE '%event.kind=response.completed%'
ORDER BY ts DESC
LIMIT 5000",
)?;
let mut rows = statement.query([since])?;
let mut breakdown = TokenBreakdown::default();
while let Some(row) = rows.next()? {
let body: Option<String> = row.get(0)?;
if let Some(body) = body {
add_response_tokens(&mut breakdown, &body);
}
}
Ok(breakdown)
}
fn add_response_tokens(breakdown: &mut TokenBreakdown, body: &str) {
let regex = Regex::new(r"(input|output|cached|reasoning|tool)_token_count=(\d+)")
.expect("static token regex is valid");
let mut matched = false;
for capture in regex.captures_iter(body) {
let count = capture
.get(2)
.and_then(|value| value.as_str().parse::<i64>().ok())
.unwrap_or(0);
match capture.get(1).map(|value| value.as_str()) {
Some("input") => breakdown.input += count,
Some("output") => breakdown.output += count,
Some("cached") => breakdown.cached += count,
Some("reasoning") => breakdown.reasoning += count,
Some("tool") => breakdown.tool += count,
_ => {}
}
matched = true;
}
if matched {
breakdown.responses += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_response_token_counts() {
let mut breakdown = TokenBreakdown::default();
add_response_tokens(
&mut breakdown,
"event.kind=response.completed input_token_count=40917 output_token_count=410 cached_token_count=39296 reasoning_token_count=32 tool_token_count=41327",
);
assert_eq!(breakdown.input, 40917);
assert_eq!(breakdown.output, 410);
assert_eq!(breakdown.cached, 39296);
assert_eq!(breakdown.reasoning, 32);
assert_eq!(breakdown.tool, 41327);
assert_eq!(breakdown.responses, 1);
}
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
codex_limit_monitor::run();
}

125
src-tauri/src/models.rs Normal file
View File

@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RateLimitBucket {
pub limit_id: String,
pub limit_name: Option<String>,
pub used_percent: f64,
pub window_duration_mins: Option<u64>,
pub resets_at: Option<i64>,
pub secondary_used_percent: Option<f64>,
pub secondary_resets_at: Option<i64>,
pub reached_type: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LiveStatus {
pub state: String,
pub detail: Option<String>,
}
impl LiveStatus {
pub fn online() -> Self {
Self {
state: "online".to_string(),
detail: None,
}
}
pub fn fallback(detail: impl Into<String>) -> Self {
Self {
state: "fallback".to_string(),
detail: Some(detail.into()),
}
}
pub fn offline(detail: impl Into<String>) -> Self {
Self {
state: "offline".to_string(),
detail: Some(detail.into()),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TokenBreakdown {
pub input: i64,
pub output: i64,
pub cached: i64,
pub reasoning: i64,
pub tool: i64,
pub responses: i64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
pub model: String,
pub tokens: i64,
pub threads: i64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ThreadUsage {
pub id: String,
pub title: String,
pub tokens_used: i64,
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub updated_at: i64,
pub cwd: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UsageSummary {
pub total_tokens: i64,
pub today_tokens: i64,
pub week_tokens: i64,
pub thread_count: i64,
pub today_thread_count: i64,
pub week_thread_count: i64,
pub current_thread: Option<ThreadUsage>,
pub recent_threads: Vec<ThreadUsage>,
pub model_breakdown: Vec<ModelUsage>,
pub token_breakdown: TokenBreakdown,
pub source_path: Option<String>,
pub error: Option<String>,
}
impl UsageSummary {
pub fn has_data(&self) -> bool {
self.thread_count > 0 || self.total_tokens > 0 || !self.recent_threads.is_empty()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MonitorSnapshot {
pub source: String,
pub fetched_at: i64,
pub live_status: LiveStatus,
pub buckets: Vec<RateLimitBucket>,
pub usage_summary: UsageSummary,
pub current_thread: Option<ThreadUsage>,
pub message: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UsagePoint {
pub label: String,
pub tokens: i64,
pub threads: i64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UsageHistory {
pub range: String,
pub points: Vec<UsagePoint>,
}

185
src-tauri/src/monitor.rs Normal file
View File

@@ -0,0 +1,185 @@
use crate::{
codex_rpc, local_usage,
models::{LiveStatus, MonitorSnapshot, RateLimitBucket},
settings::AppSettings,
tray,
};
use chrono::Utc;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tauri::{AppHandle, Emitter};
use tauri_plugin_notification::NotificationExt;
use tokio::sync::{Mutex, RwLock};
#[derive(Default)]
struct NotificationMemory {
seen: HashMap<String, String>,
}
pub struct MonitorService {
settings: RwLock<AppSettings>,
snapshot: RwLock<Option<MonitorSnapshot>>,
notifications: Mutex<NotificationMemory>,
}
impl MonitorService {
pub fn new(settings: AppSettings) -> Self {
Self {
settings: RwLock::new(settings),
snapshot: RwLock::new(None),
notifications: Mutex::new(NotificationMemory::default()),
}
}
pub async fn settings(&self) -> AppSettings {
self.settings.read().await.clone()
}
pub async fn replace_settings(&self, settings: AppSettings) {
*self.settings.write().await = settings;
}
pub async fn snapshot_or_refresh(
&self,
app: Option<AppHandle>,
) -> Result<MonitorSnapshot, String> {
if let Some(snapshot) = self.snapshot.read().await.clone() {
return Ok(snapshot);
}
self.refresh(app).await
}
pub async fn refresh(&self, app: Option<AppHandle>) -> Result<MonitorSnapshot, String> {
let settings = self.settings().await;
if let Some(app) = app.as_ref() {
let _ = app.emit("refresh_started", ());
}
let usage_summary = local_usage::read_usage_summary(&settings);
let live = codex_rpc::fetch_rate_limits(&settings).await;
let fetched_at = Utc::now().timestamp();
let (source, live_status, buckets, message) = match live {
Ok(buckets) => (
"codex-app-server".to_string(),
LiveStatus::online(),
buckets,
None,
),
Err(error) if usage_summary.has_data() => (
"local-sqlite-fallback".to_string(),
LiveStatus::fallback(error.to_string()),
Vec::new(),
Some("Live Codex limits are unavailable; showing local usage history.".to_string()),
),
Err(error) => (
"offline".to_string(),
LiveStatus::offline(error.to_string()),
Vec::new(),
Some("No live Codex limits or local usage data could be read.".to_string()),
),
};
let current_thread = usage_summary.current_thread.clone();
let snapshot = MonitorSnapshot {
source,
fetched_at,
live_status,
buckets,
usage_summary,
current_thread,
message,
};
*self.snapshot.write().await = Some(snapshot.clone());
if let Some(app) = app {
let _ = app.emit("rate_limit_updated", &snapshot);
let _ = app.emit("usage_updated", &snapshot.usage_summary);
tray::update_tray(&app, &snapshot);
self.maybe_notify(&app, &settings, &snapshot.buckets).await;
}
Ok(snapshot)
}
pub async fn run(self: Arc<Self>, app: AppHandle) {
loop {
let settings = self.settings().await;
let interval = settings.refresh_interval_secs.max(15);
let _ = self.refresh(Some(app.clone())).await;
tokio::time::sleep(Duration::from_secs(interval)).await;
}
}
async fn maybe_notify(
&self,
app: &AppHandle,
settings: &AppSettings,
buckets: &[RateLimitBucket],
) {
if settings
.notifications_muted_until
.is_some_and(|until| until > Utc::now().timestamp())
{
return;
}
let mut memory = self.notifications.lock().await;
for bucket in buckets {
let Some(level) = threshold_level(settings, bucket.used_percent) else {
continue;
};
let reset = bucket.resets_at.unwrap_or_default();
let key = format!("{}:{reset}", bucket.limit_id);
if memory.seen.get(&key).is_some_and(|seen| seen == level) {
continue;
}
memory.seen.insert(key, level.to_string());
let title = match level {
"exhausted" => "Codex limit reached",
"critical" => "Codex limit nearly reached",
_ => "Codex limit warning",
};
let body = format!(
"{} is at {:.0}% and resets {}.",
bucket
.limit_name
.as_deref()
.unwrap_or(bucket.limit_id.as_str()),
bucket.used_percent,
bucket
.resets_at
.map(format_reset)
.unwrap_or_else(|| "soon".to_string())
);
let _ = app.notification().builder().title(title).body(body).show();
}
}
}
fn threshold_level(settings: &AppSettings, percent: f64) -> Option<&'static str> {
if percent >= settings.thresholds.exhausted {
Some("exhausted")
} else if percent >= settings.thresholds.critical {
Some("critical")
} else if percent >= settings.thresholds.warning {
Some("warning")
} else {
None
}
}
fn format_reset(timestamp: i64) -> String {
let now = Utc::now().timestamp();
if timestamp <= now {
return "now".to_string();
}
let seconds = timestamp - now;
if seconds < 60 {
format!("in {seconds}s")
} else {
format!("in {}m", seconds / 60)
}
}

173
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,173 @@
use serde::{Deserialize, Serialize};
use std::{
fs, io,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BadgePosition {
pub x: i32,
pub y: i32,
}
impl Default for BadgePosition {
fn default() -> Self {
Self { x: 1530, y: 18 }
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NotificationThresholds {
pub warning: f64,
pub critical: f64,
pub exhausted: f64,
}
impl Default for NotificationThresholds {
fn default() -> Self {
Self {
warning: 70.0,
critical: 90.0,
exhausted: 100.0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub codex_executable: Option<String>,
pub codex_home: String,
pub refresh_interval_secs: u64,
pub badge_visible: bool,
pub badge_position: BadgePosition,
pub privacy_mode: bool,
pub thresholds: NotificationThresholds,
pub notifications_muted_until: Option<i64>,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
codex_executable: None,
codex_home: default_codex_home().to_string_lossy().to_string(),
refresh_interval_secs: 60,
badge_visible: true,
badge_position: BadgePosition::default(),
privacy_mode: false,
thresholds: NotificationThresholds::default(),
notifications_muted_until: None,
}
}
}
impl AppSettings {
pub fn load() -> io::Result<Self> {
let path = settings_path();
if !path.exists() {
return Ok(Self::default());
}
let body = fs::read_to_string(path)?;
let mut settings = serde_json::from_str::<Self>(&body).unwrap_or_default();
settings.normalize();
Ok(settings)
}
pub fn save(&self) -> io::Result<()> {
let path = settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(path, body)
}
pub fn codex_home_path(&self) -> PathBuf {
PathBuf::from(&self.codex_home)
}
pub fn resolve_codex_executable(&self) -> String {
if let Some(path) = self
.codex_executable
.as_ref()
.filter(|value| !value.is_empty())
{
return path.clone();
}
for candidate in codex_executable_candidates() {
if candidate.exists() {
return candidate.to_string_lossy().to_string();
}
}
"codex".to_string()
}
fn normalize(&mut self) {
if self.refresh_interval_secs < 15 {
self.refresh_interval_secs = 15;
}
if self.codex_home.trim().is_empty() {
self.codex_home = default_codex_home().to_string_lossy().to_string();
}
}
}
pub fn settings_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("CodexLimitMonitor")
.join("settings.json")
}
fn default_codex_home() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
}
fn codex_executable_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(app_data) = std::env::var_os("APPDATA") {
candidates.push(Path::new(&app_data).join("npm").join("codex.cmd"));
}
candidates.push(
Path::new("C:\\Program Files")
.join("nodejs")
.join("codex.cmd"),
);
candidates.push(Path::new("C:\\Program Files").join("nodejs").join("codex"));
if let Some(home) = dirs::home_dir() {
candidates.push(
home.join(".config")
.join("herd")
.join("bin")
.join("nvm")
.join("v25.2.1")
.join("node_modules")
.join(".bin")
.join("codex.cmd"),
);
candidates.push(
home.join(".config")
.join("herd")
.join("bin")
.join("nvm")
.join("v25.2.1")
.join("node_modules")
.join("@openai")
.join("codex")
.join("bin")
.join("codex.js"),
);
}
candidates
}

120
src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,120 @@
use crate::{models::MonitorSnapshot, show_dashboard};
use chrono::{Local, TimeZone};
use tauri::{
image::Image,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Emitter, Manager,
};
const TRAY_ID: &str = "codex-limit-monitor";
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let open = MenuItem::with_id(app, "open", "Open Dashboard", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh Now", true, None::<&str>)?;
let badge = MenuItem::with_id(app, "badge", "Toggle Badge", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &refresh, &badge, &quit])?;
TrayIconBuilder::with_id(TRAY_ID)
.icon(app_tray_icon())
.tooltip("Codex Limit Monitor")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
let _ = show_dashboard(app);
}
"refresh" => {
let app = app.clone();
let monitor = app
.try_state::<crate::AppState>()
.map(|state| state.monitor.clone());
tauri::async_runtime::spawn(async move {
if let Some(monitor) = monitor {
let _ = monitor.refresh(Some(app.clone())).await;
}
});
}
"badge" => {
let app = app.clone();
let monitor = app
.try_state::<crate::AppState>()
.map(|state| state.monitor.clone());
tauri::async_runtime::spawn(async move {
if let Some(monitor) = monitor {
let mut settings = monitor.settings().await;
settings.badge_visible = !settings.badge_visible;
if settings.save().is_ok() {
monitor.replace_settings(settings.clone()).await;
let _ = crate::apply_badge_visibility(&app, settings.badge_visible);
let _ = app.emit("settings_updated", &settings);
}
}
});
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let _ = show_dashboard(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
pub fn update_tray(app: &AppHandle, snapshot: &MonitorSnapshot) {
let Some(tray) = app.tray_by_id(TRAY_ID) else {
return;
};
let _ = tray.set_tooltip(Some(&tooltip(snapshot)));
}
fn app_tray_icon() -> Image<'static> {
Image::from_bytes(include_bytes!("../icons/icon.png")).expect("valid embedded tray icon")
}
fn tooltip(snapshot: &MonitorSnapshot) -> String {
if let Some(bucket) = snapshot.buckets.first() {
let reset = bucket
.resets_at
.map(format_reset_datetime)
.map(|value| format!(", resets {value}"))
.unwrap_or_default();
let weekly_reset = bucket
.secondary_resets_at
.map(format_reset_datetime)
.map(|value| format!(", weekly resets {value}"))
.unwrap_or_default();
let weekly = bucket
.secondary_used_percent
.map(|percent| format!("{percent:.0}% weekly"))
.unwrap_or_else(|| "weekly unavailable".to_string());
format!(
"Codex: {:.0}% current, {}{}{}",
bucket.used_percent, weekly, reset, weekly_reset
)
} else {
format!(
"Codex usage: {} tokens today ({})",
snapshot.usage_summary.today_tokens, snapshot.live_status.state
)
}
}
fn format_reset_datetime(timestamp: i64) -> String {
Local
.timestamp_opt(timestamp, 0)
.single()
.map(|date| date.format("%b %-d, %I:%M %p").to_string())
.unwrap_or_else(|| "unknown".to_string())
}

54
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,54 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex Limit Monitor",
"version": "0.1.0",
"identifier": "dev.meghdad.codex-limit-monitor",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Codex Limit Monitor",
"width": 1180,
"height": 760,
"minWidth": 900,
"minHeight": 620,
"resizable": true,
"center": true
},
{
"label": "badge",
"title": "Codex Badge",
"url": "badge.html",
"width": 164,
"height": 82,
"decorations": false,
"transparent": true,
"shadow": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"visible": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.ico",
"icons/icon.png",
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
]
}
}

355
src/App.tsx Normal file
View File

@@ -0,0 +1,355 @@
import { listen } from "@tauri-apps/api/event";
import { useEffect, useMemo, useState } from "react";
import {
getSettings,
getSnapshot,
getUsageHistory,
pauseNotifications,
refreshNow,
toggleBadge,
updateSettings,
} from "./api";
import type { AppSettings, MonitorSnapshot, UsageHistory } from "./types";
import {
currentLimitPercent,
formatCompact,
formatDateTime,
formatNumber,
statusTone,
truncateTitle,
weeklyLimitPercent,
weeklyLimitResetAt,
} from "./utils";
function App() {
const [snapshot, setSnapshot] = useState<MonitorSnapshot | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [history, setHistory] = useState<UsageHistory | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([getSnapshot(), getSettings(), getUsageHistory("7d")])
.then(([nextSnapshot, nextSettings, nextHistory]) => {
setSnapshot(nextSnapshot);
setSettings(nextSettings);
setHistory(nextHistory);
})
.catch((err) => setError(String(err)));
const unlisten = listen<MonitorSnapshot>("rate_limit_updated", (event) => {
setSnapshot(event.payload);
});
return () => {
unlisten.then((dispose) => dispose()).catch(() => undefined);
};
}, []);
const percent = currentLimitPercent(snapshot);
const weeklyPercent = weeklyLimitPercent(snapshot);
const weeklyReset = weeklyLimitResetAt(snapshot);
const tone = statusTone(percent);
const weeklyTone = statusTone(weeklyPercent);
const primaryBucket = snapshot?.buckets[0] ?? null;
const currentThreadTitle = useMemo(() => {
if (!snapshot?.currentThread) return "No active thread";
if (settings?.privacyMode) return "Hidden by privacy mode";
return truncateTitle(snapshot.currentThread.title || "Untitled thread", 88);
}, [settings?.privacyMode, snapshot?.currentThread]);
async function runRefresh() {
setBusy(true);
setError(null);
try {
setSnapshot(await refreshNow());
setHistory(await getUsageHistory("7d"));
} catch (err) {
setError(String(err));
} finally {
setBusy(false);
}
}
async function saveSettings(next: AppSettings) {
setSettings(next);
try {
setSettings(await updateSettings(next));
} catch (err) {
setError(String(err));
}
}
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<div className="brand-mark">CL</div>
<div>
<h1>Codex Limit Monitor</h1>
<p>Live limit and local usage telemetry</p>
</div>
</div>
<StatusPill state={snapshot?.liveStatus.state ?? "loading"} />
<button className="primary-action" onClick={runRefresh} disabled={busy}>
{busy ? "Refreshing" : "Refresh Now"}
</button>
{settings && (
<button
className="secondary-action"
onClick={async () => setSettings(await toggleBadge(!settings.badgeVisible))}
>
{settings.badgeVisible ? "Hide Badge" : "Show Badge"}
</button>
)}
<div className="sidebar-note">
<span>Source</span>
<strong>{snapshot?.source ?? "loading"}</strong>
</div>
</aside>
<section className="content">
<header className="topbar">
<div>
<p className="eyebrow">Windows desktop monitor</p>
<h2>Codex capacity at a glance</h2>
</div>
<div className={`connection ${snapshot?.liveStatus.state ?? "loading"}`}>
{snapshot?.liveStatus.detail ?? snapshot?.message ?? "Live app-server connection"}
</div>
</header>
{error && <div className="alert danger">{error}</div>}
{snapshot?.message && <div className="alert">{snapshot.message}</div>}
<section className="overview-grid">
<div className="limit-panel">
<div className="panel-header">
<div>
<span>Codex limits</span>
<h3>{primaryBucket?.limitName ?? primaryBucket?.limitId ?? "Codex"}</h3>
</div>
<span className={`tone-dot ${tone}`} />
</div>
<div className="limit-gauge-grid">
<LimitGauge
label="Current window"
percent={percent}
tone={tone}
detail={primaryBucket?.windowDurationMins ? `${primaryBucket.windowDurationMins}m` : "live"}
/>
<LimitGauge
label="Weekly limit"
percent={weeklyPercent}
tone={weeklyTone}
detail="7d"
/>
</div>
<div className="limit-meta">
<Metric label="Current reset" value={primaryBucket?.resetsAt ? formatDateTime(primaryBucket.resetsAt) : "Unavailable"} />
<Metric label="Weekly reset" value={weeklyReset ? formatDateTime(weeklyReset) : "Unavailable"} />
<Metric label="Reached" value={primaryBucket?.reachedType ?? "No"} />
</div>
</div>
<div className="metric-panel">
<Metric label="Today" value={formatCompact(snapshot?.usageSummary.todayTokens ?? 0)} detail={`${snapshot?.usageSummary.todayThreadCount ?? 0} threads`} />
<Metric label="Local Weekly Usage" value={formatCompact(snapshot?.usageSummary.weekTokens ?? 0)} detail={`${snapshot?.usageSummary.weekThreadCount ?? 0} threads`} />
<Metric label="All Local" value={formatCompact(snapshot?.usageSummary.totalTokens ?? 0)} detail={`${snapshot?.usageSummary.threadCount ?? 0} threads`} />
<Metric label="Responses" value={formatNumber(snapshot?.usageSummary.tokenBreakdown.responses ?? 0)} detail="from logs" />
</div>
</section>
<section className="work-grid">
<div className="panel">
<div className="panel-header">
<div>
<span>Current thread</span>
<h3>{currentThreadTitle}</h3>
</div>
</div>
<div className="thread-current">
<Metric label="Tokens" value={formatNumber(snapshot?.currentThread?.tokensUsed ?? 0)} />
<Metric label="Model" value={snapshot?.currentThread?.model ?? "Unknown"} />
<Metric label="Reasoning" value={snapshot?.currentThread?.reasoningEffort ?? "Default"} />
</div>
</div>
<div className="panel">
<div className="panel-header">
<div>
<span>Token shape</span>
<h3>Last 7 days</h3>
</div>
</div>
<TokenBars snapshot={snapshot} />
</div>
</section>
<section className="panel">
<div className="panel-header">
<div>
<span>Usage trend</span>
<h3>Daily tokens</h3>
</div>
</div>
<Trend history={history} />
</section>
{settings && (
<section className="settings-panel">
<div className="panel-header">
<div>
<span>Settings</span>
<h3>Monitor behavior</h3>
</div>
</div>
<div className="settings-grid">
<label>
Codex executable
<input
value={settings.codexExecutable ?? ""}
placeholder="codex or full path to codex.cmd"
onChange={(event) =>
setSettings({ ...settings, codexExecutable: event.target.value || null })
}
onBlur={() => saveSettings(settings)}
/>
</label>
<label>
Codex home
<input
value={settings.codexHome}
onChange={(event) => setSettings({ ...settings, codexHome: event.target.value })}
onBlur={() => saveSettings(settings)}
/>
</label>
<label>
Refresh seconds
<input
type="number"
min={15}
value={settings.refreshIntervalSecs}
onChange={(event) =>
setSettings({ ...settings, refreshIntervalSecs: Number(event.target.value) })
}
onBlur={() => saveSettings(settings)}
/>
</label>
<label className="toggle-row">
Privacy mode
<input
type="checkbox"
checked={settings.privacyMode}
onChange={(event) => saveSettings({ ...settings, privacyMode: event.target.checked })}
/>
</label>
<button
className="secondary-action"
onClick={() => pauseNotifications(Math.round(Date.now() / 1000) + 3600)}
>
Mute 1 Hour
</button>
</div>
</section>
)}
</section>
</main>
);
}
function StatusPill({ state }: { state: string }) {
return <div className={`status-pill ${state}`}>{state}</div>;
}
function Metric({
label,
value,
detail,
}: {
label: string;
value: string | number;
detail?: string;
}) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
{detail && <em>{detail}</em>}
</div>
);
}
function LimitGauge({
label,
percent,
tone,
detail,
}: {
label: string;
percent: number;
tone: string;
detail: string;
}) {
const value = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0;
return (
<div className="limit-gauge-card">
<div className={`gauge ${tone}`}>
<div style={{ "--value": value } as React.CSSProperties}>
<strong>{Number.isFinite(percent) ? `${Math.round(percent)}%` : "--"}</strong>
<span>used</span>
</div>
</div>
<div>
<strong>{label}</strong>
<span>{detail}</span>
</div>
</div>
);
}
function TokenBars({ snapshot }: { snapshot: MonitorSnapshot | null }) {
const breakdown = snapshot?.usageSummary.tokenBreakdown;
const values = [
["Input", breakdown?.input ?? 0],
["Output", breakdown?.output ?? 0],
["Cached", breakdown?.cached ?? 0],
["Reasoning", breakdown?.reasoning ?? 0],
["Tool", breakdown?.tool ?? 0],
] as const;
const max = Math.max(1, ...values.map(([, value]) => value));
return (
<div className="bars">
{values.map(([label, value]) => (
<div className="bar-row" key={label}>
<span>{label}</span>
<div>
<i style={{ width: `${Math.max(4, (value / max) * 100)}%` }} />
</div>
<strong>{formatCompact(value)}</strong>
</div>
))}
</div>
);
}
function Trend({ history }: { history: UsageHistory | null }) {
const points = history?.points ?? [];
const max = Math.max(1, ...points.map((point) => point.tokens));
if (points.length === 0) {
return <div className="empty-state">No local usage history found for this range.</div>;
}
return (
<div className="trend">
{points.map((point) => (
<div className="trend-column" key={point.label} title={`${point.label}: ${formatNumber(point.tokens)}`}>
<i style={{ height: `${Math.max(8, (point.tokens / max) * 100)}%` }} />
<span>{point.label.slice(5)}</span>
</div>
))}
</div>
);
}
export default App;

43
src/api.ts Normal file
View File

@@ -0,0 +1,43 @@
import { invoke } from "@tauri-apps/api/core";
import type {
AppSettings,
BadgePosition,
MonitorSnapshot,
UsageHistory,
} from "./types";
export function getSnapshot(): Promise<MonitorSnapshot> {
return invoke("get_snapshot");
}
export function refreshNow(): Promise<MonitorSnapshot> {
return invoke("refresh_now");
}
export function getUsageHistory(range: string): Promise<UsageHistory> {
return invoke("get_usage_history", { range });
}
export function getSettings(): Promise<AppSettings> {
return invoke("get_settings");
}
export function updateSettings(settings: AppSettings): Promise<AppSettings> {
return invoke("update_settings", { settings });
}
export function toggleBadge(visible: boolean): Promise<AppSettings> {
return invoke("toggle_badge", { visible });
}
export function setBadgePosition(position: BadgePosition): Promise<AppSettings> {
return invoke("set_badge_position", { position });
}
export function moveBadge(position: BadgePosition): Promise<void> {
return invoke("move_badge", { position });
}
export function pauseNotifications(until: number | null): Promise<AppSettings> {
return invoke("pause_notifications", { until });
}

144
src/badge.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import { listen } from "@tauri-apps/api/event";
import { getSettings, getSnapshot, moveBadge, setBadgePosition } from "./api";
import type { BadgePosition, MonitorSnapshot } from "./types";
import {
currentLimitPercent,
currentLimitResetAt,
formatCountdown,
resetWindowProgress,
statusTone,
weeklyLimitPercent,
} from "./utils";
import "./styles.css";
function Badge() {
const [snapshot, setSnapshot] = useState<MonitorSnapshot | null>(null);
const [position, setPosition] = useState<BadgePosition>({ x: 1530, y: 18 });
const [refreshing, setRefreshing] = useState(true);
const [nowMs, setNowMs] = useState(Date.now());
useEffect(() => {
Promise.all([getSnapshot(), getSettings()])
.then(([nextSnapshot, settings]) => {
setSnapshot(nextSnapshot);
setPosition(settings.badgePosition);
})
.catch(() => undefined)
.finally(() => setRefreshing(false));
const started = listen("refresh_started", () => {
setRefreshing(true);
});
const unlisten = listen<MonitorSnapshot>("rate_limit_updated", (event) => {
setSnapshot(event.payload);
setRefreshing(false);
});
const timer = window.setInterval(() => setNowMs(Date.now()), 30_000);
return () => {
window.clearInterval(timer);
started.then((dispose) => dispose()).catch(() => undefined);
unlisten.then((dispose) => dispose()).catch(() => undefined);
};
}, []);
useEffect(() => {
const blockContextMenu = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("contextmenu", blockContextMenu, true);
return () => window.removeEventListener("contextmenu", blockContextMenu, true);
}, []);
const currentPercent = currentLimitPercent(snapshot);
const weeklyPercent = weeklyLimitPercent(snapshot);
const resetAt = currentLimitResetAt(snapshot);
const resetProgress = resetWindowProgress(resetAt, nowMs);
const peakPercent = Math.max(
Number.isFinite(currentPercent) ? currentPercent : 0,
Number.isFinite(weeklyPercent) ? weeklyPercent : 0,
);
const tone = statusTone(peakPercent);
function dragBadge(event: React.PointerEvent<HTMLDivElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const startMouse = { x: event.screenX, y: event.screenY };
const startPosition = position;
let latest = startPosition;
let scheduled = false;
const calculate = (pointer: PointerEvent): BadgePosition => ({
x: Math.round(startPosition.x + pointer.screenX - startMouse.x),
y: Math.round(startPosition.y + pointer.screenY - startMouse.y),
});
const onMove = (pointer: PointerEvent) => {
latest = calculate(pointer);
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
setPosition(latest);
moveBadge(latest).catch(() => undefined);
});
};
const onUp = (pointer: PointerEvent) => {
latest = calculate(pointer);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setPosition(latest);
setBadgePosition(latest).catch(() => undefined);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp, { once: true });
}
return (
<div
className={`badge-shell ${tone}`}
onPointerDown={dragBadge}
title="Drag to move. Current and weekly Codex limits."
>
<div className={`badge-activity ${refreshing ? "active" : ""}`} aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="badge-reset" title={resetAt ? `Reset in ${formatCountdown(resetAt)}` : "Reset unavailable"}>
<i style={{ width: `${Number.isFinite(resetProgress) ? resetProgress : 0}%` }} />
</div>
<div className="badge-charts" aria-label="Codex current and weekly usage charts">
<MiniUsage label="Current" percent={currentPercent} />
<MiniUsage label="Weekly" percent={weeklyPercent} />
</div>
</div>
);
}
function MiniUsage({ label, percent }: { label: string; percent: number }) {
const value = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0;
return (
<div className="badge-chart" title={`${label}: ${Math.round(value)}%`}>
<header>
<span>{label}</span>
<strong>{Number.isFinite(percent) ? `${Math.round(value)}%` : "--"}</strong>
</header>
<div className="badge-chart-track">
<i style={{ width: `${value}%` }} />
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("badge-root") as HTMLElement).render(
<React.StrictMode>
<Badge />
</React.StrictMode>,
);

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

695
src/styles.css Normal file
View File

@@ -0,0 +1,695 @@
:root {
color-scheme: dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111318;
color: #edf0f7;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(38, 44, 54, 0.98), rgba(16, 18, 24, 1) 45%),
#111318;
}
.badge-html,
.badge-html body,
.badge-html #badge-root {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent !important;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 14px;
padding: 22px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(11, 13, 18, 0.72);
}
.brand-block {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.brand-mark {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 8px;
background: #e9f6ff;
color: #101318;
font-weight: 800;
}
.brand-block h1,
.topbar h2,
.panel h3,
.limit-panel h3,
.settings-panel h3 {
margin: 0;
letter-spacing: 0;
}
.brand-block h1 {
font-size: 16px;
}
.brand-block p,
.eyebrow,
.panel-header span,
.metric span,
.thread-row span,
.sidebar-note span {
color: #9ea7b8;
}
.brand-block p {
margin: 2px 0 0;
font-size: 12px;
}
.status-pill,
.connection {
width: fit-content;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
padding: 7px 11px;
color: #cdd5e3;
background: rgba(255, 255, 255, 0.06);
text-transform: capitalize;
}
.status-pill.online,
.connection.online {
border-color: rgba(34, 197, 94, 0.35);
color: #a7f3d0;
}
.status-pill.fallback,
.connection.fallback {
border-color: rgba(245, 158, 11, 0.42);
color: #fde68a;
}
.status-pill.offline,
.connection.offline {
border-color: rgba(239, 68, 68, 0.42);
color: #fecaca;
}
.primary-action,
.secondary-action {
min-height: 40px;
border: 0;
border-radius: 8px;
padding: 0 13px;
font-weight: 700;
}
.primary-action {
background: #f4f7fb;
color: #111318;
}
.primary-action:disabled {
opacity: 0.55;
}
.secondary-action {
border: 1px solid rgba(255, 255, 255, 0.11);
background: rgba(255, 255, 255, 0.07);
color: #edf0f7;
}
.sidebar-note {
display: grid;
gap: 4px;
margin-top: auto;
padding: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.055);
}
.sidebar-note strong {
overflow-wrap: anywhere;
}
.content {
display: flex;
flex-direction: column;
gap: 18px;
padding: 26px;
min-width: 0;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
margin: 0 0 5px;
font-size: 12px;
text-transform: uppercase;
}
.topbar h2 {
font-size: 28px;
}
.connection {
max-width: 460px;
text-transform: none;
text-align: right;
}
.alert {
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 8px;
padding: 11px 13px;
background: rgba(245, 158, 11, 0.09);
color: #fde68a;
}
.alert.danger {
border-color: rgba(239, 68, 68, 0.32);
background: rgba(239, 68, 68, 0.09);
color: #fecaca;
}
.overview-grid,
.work-grid {
display: grid;
grid-template-columns: minmax(320px, 0.92fr) minmax(340px, 1fr);
gap: 18px;
}
.work-grid.lower {
grid-template-columns: minmax(420px, 1.1fr) minmax(320px, 0.9fr);
}
.limit-panel,
.metric-panel,
.panel,
.settings-panel {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.055);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.18);
}
.limit-panel,
.panel,
.settings-panel {
padding: 18px;
}
.metric-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
}
.metric-panel .metric {
padding: 18px;
background: rgba(255, 255, 255, 0.025);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.tone-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #6b7280;
}
.tone-dot.ok {
background: #22c55e;
}
.tone-dot.warn {
background: #f59e0b;
}
.tone-dot.danger {
background: #ef4444;
}
.gauge {
display: grid;
place-items: center;
margin: 8px auto 18px;
width: 220px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, #151922 0 57%, transparent 58%),
conic-gradient(var(--accent) calc(var(--value) * 1%), rgba(255, 255, 255, 0.08) 0);
}
.gauge.ok {
--accent: #22c55e;
}
.gauge.warn {
--accent: #f59e0b;
}
.gauge.danger {
--accent: #ef4444;
}
.gauge.muted {
--accent: #64748b;
}
.gauge strong {
display: block;
font-size: 42px;
}
.gauge span {
color: #9ea7b8;
}
.limit-gauge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.limit-gauge-card {
display: grid;
justify-items: center;
gap: 10px;
min-width: 0;
padding: 14px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
}
.limit-gauge-card .gauge {
width: 132px;
margin: 0;
}
.limit-gauge-card .gauge strong {
font-size: 28px;
}
.limit-gauge-card > div:last-child {
display: grid;
gap: 2px;
text-align: center;
}
.limit-gauge-card > div:last-child span {
color: #9ea7b8;
}
.limit-meta,
.thread-current {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.metric {
display: grid;
gap: 4px;
min-width: 0;
}
.metric strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 24px;
}
.metric em {
color: #b6c0cf;
font-style: normal;
}
.bars {
display: grid;
gap: 12px;
}
.bar-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) 70px;
gap: 10px;
align-items: center;
}
.bar-row > span {
color: #b6c0cf;
}
.bar-row > div {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
}
.bar-row i {
display: block;
height: 100%;
border-radius: inherit;
background: #67e8f9;
}
.bar-row strong {
text-align: right;
}
.thread-list {
display: grid;
gap: 10px;
}
.thread-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 11px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
min-width: 0;
}
.thread-row div {
min-width: 0;
flex: 1 1 auto;
}
.thread-row strong,
.thread-row span {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-row em {
color: #e0f2fe;
font-style: normal;
font-weight: 700;
}
.trend {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(28px, 1fr);
gap: 8px;
height: 210px;
align-items: end;
}
.trend-column {
display: grid;
grid-template-rows: 1fr 18px;
gap: 7px;
height: 100%;
align-items: end;
}
.trend-column i {
display: block;
min-height: 8px;
border-radius: 5px 5px 2px 2px;
background: linear-gradient(180deg, #a7f3d0, #22c55e);
}
.trend-column span {
color: #9ea7b8;
font-size: 11px;
text-align: center;
}
.empty-state {
display: grid;
min-height: 160px;
place-items: center;
color: #9ea7b8;
}
.settings-grid {
display: grid;
grid-template-columns: 1.2fr 1.2fr 0.6fr 0.6fr auto;
gap: 12px;
align-items: end;
}
.settings-grid label {
display: grid;
gap: 7px;
color: #b6c0cf;
}
.settings-grid input {
width: 100%;
min-height: 40px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0 11px;
background: rgba(0, 0, 0, 0.22);
color: #edf0f7;
}
.toggle-row {
grid-template-columns: 1fr auto;
align-items: center;
}
.toggle-row input {
width: 20px;
}
.badge-body {
width: 100vw;
height: 100vh;
min-width: 0;
min-height: 0;
overflow: hidden;
background: transparent;
}
.badge-shell {
display: grid;
grid-template-rows: 5px minmax(0, 1fr);
align-items: stretch;
gap: 6px;
position: relative;
width: 164px;
height: 82px;
margin: 0;
border: 1px solid rgba(34, 197, 94, 0.48);
border-radius: 8px;
padding: 10px 10px 12px;
color: white;
background: rgba(17, 19, 24, 0.92);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
cursor: move;
user-select: none;
}
.badge-shell.ok {
border-color: rgba(34, 197, 94, 0.48);
}
.badge-shell.warn {
border-color: rgba(245, 158, 11, 0.55);
}
.badge-shell.danger {
border-color: rgba(239, 68, 68, 0.62);
}
.badge-shell span {
color: #cbd5e1;
font-size: 12px;
}
.badge-charts {
display: grid;
gap: 6px;
min-width: 0;
}
.badge-chart {
display: grid;
grid-template-rows: 12px 8px;
gap: 3px;
min-width: 0;
}
.badge-chart header {
display: flex;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.badge-chart span,
.badge-chart strong {
color: #9ea7b8;
font-size: 10px;
font-weight: 800;
line-height: 1;
}
.badge-chart strong {
color: #edf0f7;
text-align: right;
}
.badge-chart-track {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.13);
}
.badge-chart i {
display: block;
height: 100%;
min-width: 3px;
border-radius: inherit;
background: #22c55e;
}
.badge-shell.warn .badge-chart i {
background: #f59e0b;
}
.badge-shell.danger .badge-chart i {
background: #ef4444;
}
.badge-activity {
position: absolute;
top: 0;
left: 8px;
right: 8px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
opacity: 0;
pointer-events: none;
}
.badge-activity.active {
opacity: 1;
}
.badge-activity i {
display: block;
height: 3px;
border-radius: 0 0 999px 999px;
background: #67e8f9;
animation: badge-pulse 0.8s ease-in-out infinite;
}
.badge-reset {
align-self: end;
height: 5px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.badge-reset i {
display: block;
min-width: 3px;
height: 100%;
border-radius: inherit;
background: #67e8f9;
}
.badge-activity i:nth-child(2) {
animation-delay: 0.12s;
}
.badge-activity i:nth-child(3) {
animation-delay: 0.24s;
}
@keyframes badge-pulse {
0%,
100% {
opacity: 0.25;
transform: scaleX(0.72);
}
50% {
opacity: 1;
transform: scaleX(1);
}
}
@media (max-width: 980px) {
.app-shell,
.overview-grid,
.work-grid,
.work-grid.lower {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.settings-grid {
grid-template-columns: 1fr;
}
}

96
src/types.ts Normal file
View File

@@ -0,0 +1,96 @@
export type RateLimitBucket = {
limitId: string;
limitName: string | null;
usedPercent: number;
windowDurationMins: number | null;
resetsAt: number | null;
secondaryUsedPercent: number | null;
secondaryResetsAt: number | null;
reachedType: string | null;
};
export type LiveStatus = {
state: "online" | "fallback" | "offline" | string;
detail: string | null;
};
export type TokenBreakdown = {
input: number;
output: number;
cached: number;
reasoning: number;
tool: number;
responses: number;
};
export type ThreadUsage = {
id: string;
title: string;
tokensUsed: number;
model: string | null;
reasoningEffort: string | null;
updatedAt: number;
cwd: string | null;
};
export type ModelUsage = {
model: string;
tokens: number;
threads: number;
};
export type UsageSummary = {
totalTokens: number;
todayTokens: number;
weekTokens: number;
threadCount: number;
todayThreadCount: number;
weekThreadCount: number;
currentThread: ThreadUsage | null;
recentThreads: ThreadUsage[];
modelBreakdown: ModelUsage[];
tokenBreakdown: TokenBreakdown;
sourcePath: string | null;
error: string | null;
};
export type MonitorSnapshot = {
source: string;
fetchedAt: number;
liveStatus: LiveStatus;
buckets: RateLimitBucket[];
usageSummary: UsageSummary;
currentThread: ThreadUsage | null;
message: string | null;
};
export type UsagePoint = {
label: string;
tokens: number;
threads: number;
};
export type UsageHistory = {
range: string;
points: UsagePoint[];
};
export type BadgePosition = {
x: number;
y: number;
};
export type AppSettings = {
codexExecutable: string | null;
codexHome: string;
refreshIntervalSecs: number;
badgeVisible: boolean;
badgePosition: BadgePosition;
privacyMode: boolean;
thresholds: {
warning: number;
critical: number;
exhausted: number;
};
notificationsMutedUntil: number | null;
};

65
src/utils.test.ts Normal file
View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
currentLimitPercent,
formatCountdown,
resetWindowProgress,
statusTone,
truncateTitle,
weeklyLimitPercent,
weeklyLimitResetAt,
} from "./utils";
import type { MonitorSnapshot } from "./types";
describe("statusTone", () => {
it("maps percentages to tones", () => {
expect(statusTone(Number.NaN)).toBe("muted");
expect(statusTone(10)).toBe("ok");
expect(statusTone(70)).toBe("warn");
expect(statusTone(90)).toBe("danger");
});
});
describe("formatCountdown", () => {
it("formats future reset timestamps", () => {
const future = Math.round(Date.now() / 1000) + 120;
expect(formatCountdown(future)).toBe("2m");
});
});
describe("limit helpers", () => {
it("separates current and weekly app-server percentages", () => {
const snapshot = {
buckets: [
{
limitId: "codex",
limitName: null,
usedPercent: 8,
windowDurationMins: 300,
resetsAt: 100,
secondaryUsedPercent: 44,
secondaryResetsAt: 200,
reachedType: null,
},
],
} as MonitorSnapshot;
expect(currentLimitPercent(snapshot)).toBe(8);
expect(weeklyLimitPercent(snapshot)).toBe(44);
expect(weeklyLimitResetAt(snapshot)).toBe(200);
});
});
describe("truncateTitle", () => {
it("limits long titles", () => {
expect(truncateTitle("abcdefghijklmnopqrstuvwxyz", 10)).toBe("abcdefg...");
});
});
describe("resetWindowProgress", () => {
it("fills based on elapsed time in the five hour reset window", () => {
const resetAt = Date.UTC(2026, 0, 1, 5, 0, 0) / 1000;
const nowMs = Date.UTC(2026, 0, 1, 1, 0, 0);
expect(resetWindowProgress(resetAt, nowMs)).toBe(20);
});
});

92
src/utils.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { MonitorSnapshot } from "./types";
export function formatNumber(value: number): string {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value);
}
export function formatCompact(value: number): string {
return new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
}
export function maxBucketPercent(snapshot: MonitorSnapshot | null): number {
if (!snapshot || snapshot.buckets.length === 0) return Number.NaN;
return Math.max(...snapshot.buckets.map((bucket) => bucket.usedPercent));
}
export function currentLimitPercent(snapshot: MonitorSnapshot | null): number {
return snapshot?.buckets[0]?.usedPercent ?? Number.NaN;
}
export function weeklyLimitPercent(snapshot: MonitorSnapshot | null): number {
const primary = snapshot?.buckets[0];
if (typeof primary?.secondaryUsedPercent === "number") {
return primary.secondaryUsedPercent;
}
const weeklyBucket = snapshot?.buckets.find(
(bucket) => (bucket.windowDurationMins ?? 0) >= 10_080,
);
return weeklyBucket?.usedPercent ?? Number.NaN;
}
export function weeklyLimitResetAt(snapshot: MonitorSnapshot | null): number | null {
const primary = snapshot?.buckets[0];
if (typeof primary?.secondaryResetsAt === "number") {
return primary.secondaryResetsAt;
}
const weeklyBucket = snapshot?.buckets.find(
(bucket) => (bucket.windowDurationMins ?? 0) >= 10_080,
);
return weeklyBucket?.resetsAt ?? null;
}
export function currentLimitResetAt(snapshot: MonitorSnapshot | null): number | null {
return snapshot?.buckets[0]?.resetsAt ?? null;
}
export function resetWindowProgress(
resetAt: number | null | undefined,
nowMs = Date.now(),
windowHours = 5,
): number {
if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return Number.NaN;
const windowMs = windowHours * 60 * 60 * 1000;
const resetMs = resetAt * 1000;
const startMs = resetMs - windowMs;
return Math.max(0, Math.min(100, ((nowMs - startMs) / windowMs) * 100));
}
export function statusTone(percent: number): "ok" | "warn" | "danger" | "muted" {
if (!Number.isFinite(percent)) return "muted";
if (percent >= 90) return "danger";
if (percent >= 70) return "warn";
return "ok";
}
export function formatCountdown(timestamp: number): string {
const seconds = Math.max(0, Math.round(timestamp - Date.now() / 1000));
if (seconds === 0) return "resetting";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${Math.round(seconds / 3600)}h`;
}
export function formatDateTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(timestamp * 1000));
}
export function truncateTitle(title: string | null | undefined, max = 74): string {
const clean = (title ?? "Untitled").trim() || "Untitled";
if (clean.length <= max) return clean;
if (max <= 3) return clean.slice(0, max);
return `${clean.slice(0, max - 3)}...`;
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
strictPort: true,
host: "127.0.0.1",
port: 1420,
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: "es2022",
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
badge: resolve(__dirname, "badge.html"),
},
},
},
});