1- use std:: collections:: HashMap ;
1+ use std:: collections:: { HashMap , HashSet } ;
22use std:: path:: { Path , PathBuf } ;
33
44use 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
148170fn 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+
152186impl 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