init
26
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
27
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
src-tauri/icons/icon-24.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/icon-256.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/icon-64.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
248
src-tauri/src/codex_rpc.rs
Normal 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
@@ -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");
|
||||
}
|
||||
275
src-tauri/src/local_usage.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||