Skip to content

Commit 676e6b0

Browse files
committed
design polish for tray and widget
1 parent e1c5b95 commit 676e6b0

30 files changed

Lines changed: 3172 additions & 130 deletions

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/capabilities/default.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"opener:default",
2929
"opener:allow-open-url",
3030
"notification:default",
31-
"log:default"
31+
"log:default",
32+
"main-commands"
3233
]
3334
}

src-tauri/capabilities/widget.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"$schema": "../gen/schemas/desktop-schema.json",
3+
"identifier": "widget",
4+
"description": "Least-privilege permissions for the AgentsBar widget window.",
5+
"windows": ["widget"],
6+
"permissions": [
7+
"core:window:allow-start-dragging",
8+
"core:event:allow-listen",
9+
"core:event:allow-unlisten",
10+
"widget-commands"
11+
]
12+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[[permission]]
2+
identifier = "main-commands"
3+
description = "Commands used by the primary AgentsBar window."
4+
commands.allow = [
5+
"list_providers",
6+
"get_snapshots",
7+
"refresh_all",
8+
"refresh_provider",
9+
"get_config",
10+
"get_cadence_secs",
11+
"set_config",
12+
"set_api_key",
13+
"quit_app",
14+
"toggle_widget",
15+
"list_browsers",
16+
"set_cookie_source",
17+
"set_cookie_header",
18+
"get_history",
19+
"get_health",
20+
"export_diagnostics",
21+
"clear_cookie_cache",
22+
]
23+
24+
[[permission]]
25+
identifier = "widget-commands"
26+
description = "Usage data plus the widget's own order and hide actions."
27+
commands.allow = ["get_config", "list_providers", "get_snapshots", "set_provider_order", "toggle_widget"]

src-tauri/src/commands.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use std::collections::HashMap;
22

33
use chrono::{DateTime, Utc};
44
use serde::Serialize;
5-
use tauri::{AppHandle, Manager, State};
5+
use tauri::{AppHandle, Emitter, Manager, State};
66

77
use crate::config::{Config, COOKIE_BROWSERS, COOKIE_SOURCES};
8+
use crate::health::HealthPoint;
89
use crate::history::Sample;
910
use crate::providers::{all_providers, AuthKind, ProviderInfo, UsageSnapshot, UsageWindow};
1011
use crate::state::{AppState, DisplaySnapshot};
@@ -61,6 +62,24 @@ pub async fn get_config(state: State<'_, AppState>) -> Result<Config, String> {
6162
Ok(state.config.read().await.redacted())
6263
}
6364

65+
#[tauri::command]
66+
pub async fn set_provider_order(app: AppHandle, order: Vec<String>) -> Result<Vec<String>, String> {
67+
let order = {
68+
let state = app.state::<AppState>();
69+
let mut config = state.config.write().await;
70+
let mut next = config.clone();
71+
next.provider_order = order;
72+
next.normalize();
73+
next.save().map_err(|e| e.to_string())?;
74+
let order = next.provider_order.clone();
75+
*config = next;
76+
order
77+
};
78+
app.emit("provider-order-updated", &order)
79+
.map_err(|e| e.to_string())?;
80+
Ok(order)
81+
}
82+
6483
/// Row 20 and row 24. The longest gap the ACTIVE cadence policy can leave between two
6584
/// batches, in seconds, for the frontend's staleness threshold.
6685
///
@@ -125,6 +144,11 @@ pub fn quit_app(app: AppHandle) {
125144
app.exit(0);
126145
}
127146

147+
#[tauri::command]
148+
pub fn toggle_widget(app: AppHandle) {
149+
crate::tray::toggle_widget(&app);
150+
}
151+
128152
// ------------------------------------------------------------------ cookies
129153

130154
#[derive(Debug, Serialize)]
@@ -234,6 +258,13 @@ pub async fn get_history(
234258
Ok(state.history.read().await.series().clone())
235259
}
236260

261+
#[tauri::command]
262+
pub async fn get_health(
263+
state: State<'_, AppState>,
264+
) -> Result<HashMap<String, Vec<HealthPoint>>, String> {
265+
Ok(state.health.read().await.series().clone())
266+
}
267+
237268
// ------------------------------------------------------------------ diagnostics
238269

239270
/// Row 27. Drop the memoized cookie scans, so the next fetch re-reads the browser.

src-tauri/src/config.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, HashSet};
22
use std::path::{Path, PathBuf};
33

44
use base64::Engine as _;
@@ -134,21 +134,55 @@ pub struct Config {
134134
#[serde(default)]
135135
pub refresh_adaptive: bool,
136136
pub pinned_provider: Option<String>,
137+
/// User's explicit provider order. An empty list uses the live usage ranking.
138+
#[serde(default)]
139+
pub provider_order: Vec<String>,
137140
pub providers: HashMap<String, ProviderConfig>,
138141
pub launch_at_startup: bool,
139142
pub theme: String,
140143
#[serde(default = "default_true")]
141144
pub notify_on_exhaustion: bool,
145+
/// Advisor. Notify when a provider's remaining quota first drops to or below this
146+
/// percentage. `None`, the default, is OFF: the advisor is opt-in, and a config.json
147+
/// written before the field existed has no key, which reads as `None`, so an existing
148+
/// install keeps its silence.
149+
#[serde(default)]
150+
pub alert_below_percent: Option<u8>,
151+
/// Advisor. Quiet hours as local hours of the day, 0-23. Alert toasts are suppressed
152+
/// inside `[start, end)` when BOTH ends are set; either one `None` means no quiet
153+
/// hours. A start after the end wraps midnight (22 to 7 is quiet from 10pm to 7am).
154+
#[serde(default)]
155+
pub alert_quiet_start: Option<u8>,
156+
#[serde(default)]
157+
pub alert_quiet_end: Option<u8>,
142158
/// Optional HTTP(S) proxy all provider requests are routed through, for corporate
143159
/// networks that egress via one. `None` (the default) is a direct connection.
144160
#[serde(default)]
145161
pub proxy_url: Option<String>,
162+
/// Last screen position of the desktop widget, so it reopens where it was dragged.
163+
/// `None` until the widget has been placed at least once.
164+
#[serde(default)]
165+
pub widget_x: Option<i32>,
166+
#[serde(default)]
167+
pub widget_y: Option<i32>,
146168
}
147169

148170
fn default_true() -> bool {
149171
true
150172
}
151173

174+
/// Advisor. `[start, end)` over the hours of a day, wrapping midnight when the start is
175+
/// after the end. Equal ends are the empty window, which is never quiet: the UI sets
176+
/// both selects deliberately, and a hand edited file landing both on the same hour must
177+
/// read as "off", not as "always quiet".
178+
fn in_quiet_window(start: u8, end: u8, hour: u8) -> bool {
179+
if start <= end {
180+
(start..end).contains(&hour)
181+
} else {
182+
hour >= start || hour < end
183+
}
184+
}
185+
152186
impl Default for Config {
153187
fn default() -> Self {
154188
let providers = crate::providers::all_providers()
@@ -159,11 +193,17 @@ impl Default for Config {
159193
refresh_minutes: 5,
160194
refresh_adaptive: true,
161195
pinned_provider: None,
196+
provider_order: vec![],
162197
providers,
163198
launch_at_startup: false,
164199
theme: "auto".to_string(),
165200
notify_on_exhaustion: true,
201+
alert_below_percent: None,
202+
alert_quiet_start: None,
203+
alert_quiet_end: None,
166204
proxy_url: None,
205+
widget_x: None,
206+
widget_y: None,
167207
}
168208
}
169209
}
@@ -218,12 +258,24 @@ impl Config {
218258
/// The upper bound keeps `refresh_minutes * 60` from overflowing a u64 duration.
219259
pub fn normalize(&mut self) {
220260
self.refresh_minutes = self.refresh_minutes.clamp(1, 1440);
261+
// Advisor values come from a hand editable file: clamp them the way
262+
// `refresh_minutes` is clamped, instead of trusting them.
263+
self.alert_below_percent = self.alert_below_percent.map(|p| p.min(100));
264+
self.alert_quiet_start = self.alert_quiet_start.map(|h| h.min(23));
265+
self.alert_quiet_end = self.alert_quiet_end.map(|h| h.min(23));
221266
// A blank or whitespace proxy is no proxy at all.
222267
self.proxy_url = self
223268
.proxy_url
224269
.take()
225270
.map(|p| p.trim().to_string())
226271
.filter(|p| !p.is_empty());
272+
let known = crate::providers::all_providers()
273+
.iter()
274+
.map(|provider| provider.id())
275+
.collect::<HashSet<_>>();
276+
let mut seen = HashSet::new();
277+
self.provider_order
278+
.retain(|id| known.contains(id.as_str()) && seen.insert(id.clone()));
227279
for provider in self.providers.values_mut() {
228280
provider.unwrap_secrets();
229281
// An unknown source would silently disable a provider, so fall back to auto.
@@ -304,6 +356,17 @@ impl Config {
304356
self.providers.get(id).is_some_and(|p| p.enabled)
305357
}
306358

359+
/// Advisor. Whether alert toasts are suppressed right now. Only active when both
360+
/// quiet hour ends are set; the window is `[start, end)` on the LOCAL clock, because
361+
/// quiet hours are a sleep schedule, not a UTC interval.
362+
pub fn quiet_now(&self) -> bool {
363+
use chrono::Timelike;
364+
let (Some(start), Some(end)) = (self.alert_quiet_start, self.alert_quiet_end) else {
365+
return false;
366+
};
367+
in_quiet_window(start, end, chrono::Local::now().hour() as u8)
368+
}
369+
307370
/// Always one of [`COOKIE_SOURCES`]; providers absent from the file default to auto.
308371
pub fn cookie_source(&self, id: &str) -> &str {
309372
self.providers
@@ -640,6 +703,17 @@ mod tests {
640703
assert!(again.is_enabled("openrouter"));
641704
}
642705

706+
#[test]
707+
fn provider_order_only_keeps_known_ids_once() {
708+
let cfg =
709+
Config::parse(r#"{"provider_order":["claude","not-a-provider","claude","codex"]}"#);
710+
let value = serde_json::to_value(cfg).unwrap();
711+
assert_eq!(
712+
value["provider_order"],
713+
serde_json::json!(["claude", "codex"])
714+
);
715+
}
716+
643717
/// Row 24, "fresh installs only". The FIELD level `#[serde(default)]` on
644718
/// `refresh_adaptive` is the whole migration: delete it and the container default fills
645719
/// the missing key from `Config::default()`, which is `true`, and every wave 3 install
@@ -798,4 +872,63 @@ mod tests {
798872
assert_eq!(cfg.cookie_browser("cursor"), Some("edge"));
799873
assert_eq!(cfg.cookie_header("cursor"), Some("a=1"));
800874
}
875+
876+
// ----------------------------------------------------------------------- advisor
877+
878+
/// The advisor is opt-in: a config written before these keys existed reads them as
879+
/// absent and stays silent, and a hand edited file cannot push a value past its
880+
/// meaning (a threshold above 100, an hour above 23).
881+
#[test]
882+
fn the_advisor_defaults_to_off_and_clamps_hand_edited_values() {
883+
let cfg = Config::parse(r#"{"refresh_minutes":7}"#);
884+
assert_eq!(cfg.alert_below_percent, None);
885+
assert_eq!(cfg.alert_quiet_start, None);
886+
assert_eq!(cfg.alert_quiet_end, None);
887+
assert!(!cfg.quiet_now(), "no ends set is never quiet");
888+
889+
let cfg = Config::parse(
890+
r#"{"alert_below_percent":250,"alert_quiet_start":99,"alert_quiet_end":24}"#,
891+
);
892+
assert_eq!(cfg.alert_below_percent, Some(100));
893+
assert_eq!(cfg.alert_quiet_start, Some(23));
894+
assert_eq!(cfg.alert_quiet_end, Some(23));
895+
896+
// Only one end set is no quiet hours at all, whatever the hour is.
897+
let start_only = Config {
898+
alert_quiet_start: Some(0),
899+
..Config::default()
900+
};
901+
assert!(!start_only.quiet_now());
902+
let end_only = Config {
903+
alert_quiet_end: Some(23),
904+
..Config::default()
905+
};
906+
assert!(!end_only.quiet_now());
907+
}
908+
909+
/// Quiet hours are `[start, end)`: the end is exclusive, a wrapping window covers
910+
/// both sides of midnight, and equal ends are the empty window, not a 24 hour one.
911+
#[test]
912+
fn quiet_hours_wrap_midnight_and_exclude_the_end() {
913+
// A same-day window, 9 until 17.
914+
for hour in 9u8..17 {
915+
assert!(in_quiet_window(9, 17, hour), "{hour} is inside 9-17");
916+
}
917+
for hour in [0u8, 8, 17, 23] {
918+
assert!(!in_quiet_window(9, 17, hour), "{hour} is outside 9-17");
919+
}
920+
921+
// 22 until 7 wraps midnight.
922+
for hour in [22u8, 23, 0, 1, 6] {
923+
assert!(in_quiet_window(22, 7, hour), "{hour} is inside 22-7");
924+
}
925+
for hour in [7u8, 12, 21] {
926+
assert!(!in_quiet_window(22, 7, hour), "{hour} is outside 22-7");
927+
}
928+
929+
// Equal ends are the empty window: never quiet.
930+
for hour in 0u8..24 {
931+
assert!(!in_quiet_window(5, 5, hour));
932+
}
933+
}
801934
}

0 commit comments

Comments
 (0)