Skip to main content

pitboard_core/
statusline.rs

1//! `pitboard statusline`: one line for Claude Code's status bar, naming the account in use
2//! and what every enrolled account has left.
3//!
4//! Claude Code runs it after every message with its session as JSON on stdin, including the
5//! limits of the account that session is using, and on a timer too when its settings ask.
6//! So this reads only files, with no keychain and no network, and writes two. What the
7//! session passed is kept for its next run to compare with, and what moved since its last
8//! run goes into Pitboard's readings as the account in use's, where it can be that
9//! account's; they keep it only where it is newer than what they have. The account in use
10//! shows its reading with that folded in, so a session left open shows what the busy ones
11//! have recorded since. The other accounts show Pitboard's last reading of them, with its
12//! age once that is worth knowing.
13//!
14//! It is Claude Code's status bar, so it is about Claude Code's accounts and nothing else.
15//! The account in use is the one Claude Code's own record names, looked up among Claude
16//! Code's accounts only, and the others listed are the ones this session could be switched
17//! to. A Codex account is neither, whatever its label or its identity happens to be.
18
19use crate::context::Context;
20use crate::pace::Standing;
21use crate::provider::ProviderId;
22use crate::sessions::{Limit, Run};
23use crate::state::State;
24use crate::usage::{Snapshot, Source, Window};
25use serde_json::Value;
26use std::collections::{BTreeMap, HashMap};
27
28/// Older than this, a remembered reading shows its age.
29const FRESH_FOR: i64 = 15 * 60;
30
31/// The share of the five-hour and weekly limits already used, when known.
32#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct Shares {
34    pub five_hour: Option<f64>,
35    pub weekly: Option<f64>,
36}
37
38/// How fast the account in use is going on its five-hour and weekly limits, where that
39/// means something. Only the account in use: the others are parked, and not being used.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub struct Paces {
42    pub five_hour: Option<Standing>,
43    pub weekly: Option<Standing>,
44}
45
46/// An enrolled account other than the one in use.
47#[derive(Debug, PartialEq)]
48pub struct Entry {
49    pub label: String,
50    pub shares: Shares,
51    /// Seconds since this was measured, once that is worth knowing.
52    pub age: Option<i64>,
53}
54
55#[derive(Debug, PartialEq)]
56pub struct StatusLine {
57    /// The account Claude Code's config names, or `None` when it is not enrolled.
58    pub current: Option<String>,
59    /// The session's own account: Pitboard's reading of it, with whatever of the session's
60    /// numbers can be this account's folded in where they are newer.
61    pub session: Shares,
62    /// The session's own account's pace on each of those limits.
63    pub pace: Paces,
64    pub others: Vec<Entry>,
65}
66
67/// What the line calls its two limits, by the names a reading can give each.
68const FIVE_HOUR: &[&str] = &["session", "five_hour"];
69const WEEKLY: &[&str] = &["weekly_all", "seven_day"];
70
71/// The limit of a reading called one of `kinds`, for every model.
72fn limit<'a>(reading: &'a Snapshot, kinds: &[&str]) -> Option<&'a Window> {
73    reading
74        .windows
75        .iter()
76        .find(|w| w.scope.is_none() && kinds.contains(&w.kind.as_str()))
77}
78
79/// The five-hour and weekly shares of a reading.
80fn shares_of(reading: &Snapshot, now: i64) -> Shares {
81    let share = |kinds| limit(reading, kinds).map(|w| w.used(now));
82    Shares {
83        five_hour: share(FIVE_HOUR),
84        weekly: share(WEEKLY),
85    }
86}
87
88/// The five-hour and weekly paces of a reading, as of when it was taken. What a session
89/// passed carries no time, and is the session's latest.
90fn paces_of(reading: &Snapshot, now: i64) -> Paces {
91    let at = reading.observed_at.unwrap_or(now);
92    let pace = |kinds| Some(limit(reading, kinds)?.pace(at, now)?.standing);
93    Paces {
94        five_hour: pace(FIVE_HOUR),
95        weekly: pace(WEEKLY),
96    }
97}
98
99/// `signed_in` is the id of the account Claude Code's config names, which is only ever
100/// looked up among Claude Code's accounts. `offered` is what the session's numbers came to
101/// as that account's, if anything.
102fn line(
103    state: &State,
104    signed_in: Option<&str>,
105    remembered: &HashMap<String, Snapshot>,
106    offered: Option<&Snapshot>,
107    now: i64,
108) -> StatusLine {
109    // Claude Code runs this, so the line is about Claude Code's accounts. Another tool's
110    // account is not something this session could switch to.
111    let current = signed_in.and_then(|id| state.by_id(ProviderId::Claude, id));
112    let others = state
113        .accounts
114        .iter()
115        .filter(|a| a.provider() == ProviderId::Claude)
116        .filter(|a| current.is_none_or(|c| c.id != a.id))
117        .map(|account| {
118            let reading = remembered.get(&account.id);
119            Entry {
120                label: account.label.clone(),
121                shares: reading.map_or_else(Shares::default, |r| shares_of(r, now)),
122                age: reading
123                    .and_then(|r| r.observed_at)
124                    .map(|at| now - at)
125                    .filter(|age| *age > FRESH_FOR),
126            }
127        })
128        .collect();
129    let known = signed_in.and_then(|id| remembered.get(id));
130    let in_use = crate::usage::merge(known, offered, now);
131    StatusLine {
132        current: current.map(|a| a.label.clone()),
133        session: in_use
134            .as_ref()
135            .map_or_else(Shares::default, |r| shares_of(r, now)),
136        pace: in_use
137            .as_ref()
138            .map_or_else(Paces::default, |r| paces_of(r, now)),
139        others,
140    }
141}
142
143/// What this run of a session's status line was given: the account Claude Code's config
144/// names, and each limit the session passed.
145fn run_of(input: &Value, signed_in: Option<&str>) -> Run {
146    let limits = input.get("rate_limits");
147    Run {
148        account: signed_in.map(str::to_owned),
149        limits: ["five_hour", "seven_day"]
150            .into_iter()
151            .filter_map(|name| {
152                let w = limits?.get(name)?;
153                let limit = Limit {
154                    used_percentage: w.get("used_percentage")?.as_f64()?,
155                    resets_at: w.get("resets_at").and_then(Value::as_i64),
156                };
157                Some((name.to_string(), limit))
158            })
159            .collect::<BTreeMap<_, _>>(),
160    }
161}
162
163/// What the session passed that can be the account in use's, as a reading to offer it.
164/// Free: these are numbers the session already had, not a question asked of anyone.
165///
166/// It carries no time. The numbers are what the session's last response said, however long
167/// ago that was, so the readings stamp them only when they move something forward.
168///
169/// Nor does it say whose they are. A session passes the numbers of its last response every
170/// time its status line runs, and one left idle passes the same ones for as long as it
171/// stays open, whatever the config has named since, by a switch or a `/login`, and whether
172/// or not Pitboard still knows the account they were. A change is what says something. A
173/// limit that appeared or moved since `before`, this session's previous run, came with a
174/// response the session got since, and when the config named the same account then as now,
175/// that response was on this account. So only such a limit is offered, and nothing at all
176/// from a session not seen before or one whose account the config has changed since. An
177/// idle session repeating old numbers is never taken for anyone.
178///
179/// For as long as sessions take to follow a switch, counted from when Pitboard last put the
180/// account to use, nothing is offered either: a session still on the account before gets
181/// that account's responses however the config reads. A `/login` in Claude Code is followed
182/// as slowly and leaves Pitboard no time to count from, so a window another of Claude
183/// Code's accounts has recorded, and this one has not, is left out too: its reset shows it
184/// to be that account's. And a window whose reset has passed says nothing about now.
185///
186/// A session says how much of each limit is used and when it resets, and nothing else. So a
187/// window is the one Pitboard already has for that limit with those two numbers put in: its
188/// name, and whether the account is working against it, stay as Anthropic said. A share the
189/// session has moved is one Anthropic has not graded.
190fn session_snapshot(
191    run: &Run,
192    before: Option<&Run>,
193    state: &State,
194    remembered: &HashMap<String, Snapshot>,
195    now: i64,
196) -> Option<Snapshot> {
197    let signed_in = run.account.as_deref()?;
198    let before = before.filter(|b| b.account == run.account)?;
199    let adopting = state
200        .by_id(ProviderId::Claude, signed_in)
201        .and_then(|account| account.last_used_at)
202        .is_some_and(|at| {
203            (0..i64::from(crate::switch::ADOPTION_CEILING_SECONDS)).contains(&(now - at))
204        });
205    if adopting {
206        return None;
207    }
208    let known = remembered.get(signed_in);
209    // Claude Code's accounts only: a Codex reading is of other limits, whatever they are
210    // called.
211    let others: Vec<&Snapshot> = state
212        .accounts
213        .iter()
214        .filter(|a| a.provider() == ProviderId::Claude)
215        .filter(|a| a.id != signed_in)
216        .filter_map(|a| remembered.get(&a.id))
217        .collect();
218    let has = |reading: &Snapshot, window: &Window| {
219        reading.windows.iter().any(|had| had.same_window(window))
220    };
221    // Passed under the older names, recorded under the ones Anthropic's answer uses, so an
222    // account's reading reads the same whoever took it.
223    let window = |passed: &str, named: &str| -> Option<Window> {
224        let limit = run.limits.get(passed)?;
225        if before.limits.get(passed) == Some(limit) {
226            return None;
227        }
228        let given = Window {
229            kind: named.to_string(),
230            scope: None,
231            severity: None,
232            percent: limit.used_percentage,
233            resets_at: limit.resets_at,
234            is_active: true,
235            length_seconds: crate::usage::anthropic_window_length(named),
236        };
237        let over = given.resets_at.is_some_and(|at| at <= now);
238        let anothers =
239            !known.is_some_and(|k| has(k, &given)) && others.iter().any(|r| has(r, &given));
240        if over || anothers {
241            return None;
242        }
243        Some(
244            match known.and_then(|k| k.windows.iter().find(|h| h.same_limit(&given))) {
245                Some(had) => Window {
246                    percent: given.percent,
247                    resets_at: given.resets_at,
248                    severity: None,
249                    ..had.clone()
250                },
251                None => given,
252            },
253        )
254    };
255    let windows: Vec<Window> = [("five_hour", "session"), ("seven_day", "weekly_all")]
256        .into_iter()
257        .filter_map(|(passed, named)| window(passed, named))
258        .collect();
259    (!windows.is_empty()).then_some(Snapshot {
260        windows,
261        observed_at: None,
262        account_uuid: None,
263        source: Source::Live,
264    })
265}
266
267/// Reads Claude Code's session JSON. Never fails: a status bar has nowhere to show an
268/// error, so whatever cannot be read is left out.
269///
270/// It also offers the readings what moved since the session's last run, as the account in
271/// use's wherever it can be that account's, and they keep it where it is newer. So the
272/// accounts a person actually works in stop reading as unknown without anyone running
273/// `pitboard` by hand, and every session and the menu bar show the newest numbers any of
274/// them has seen. It still asks nobody anything: no network, no credential.
275///
276/// Without a `permit`, which a run as root or under sudo is not given, it writes nothing:
277/// the session's last run is read and this one is not kept, and the readings are offered
278/// nothing. The line is drawn the same way from the files as they are.
279pub fn read(ctx: &Context, permit: Option<crate::service::Permit>, input: &str) -> StatusLine {
280    let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
281    let state = crate::state::load(ctx).unwrap_or_default();
282    // Claude Code's own record of who is signed in, which is its config: a file, and so
283    // something a status bar can afford to read after every message.
284    let signed_in = crate::provider::of(ProviderId::Claude)
285        .recorded_identity(ctx)
286        .map(|found| state.id_of(ProviderId::Claude, &crate::api::Owner::from(found)));
287    let now = ctx.now();
288    let remembered = crate::readings::load(ctx);
289    let run = run_of(&input, signed_in.as_deref());
290    let before = input
291        .get("session_id")
292        .and_then(Value::as_str)
293        .and_then(|id| match permit {
294            Some(permit) => crate::sessions::exchange(ctx, permit, id, &run),
295            None => crate::sessions::last(ctx, id),
296        });
297    let offered = session_snapshot(&run, before.as_ref(), &state, &remembered, now);
298    if let (Some(permit), Some(id), Some(offered)) =
299        (permit, signed_in.as_deref(), offered.as_ref())
300    {
301        crate::readings::remember(ctx, permit, &[(id.to_string(), offered.clone())]);
302    }
303    line(
304        &state,
305        signed_in.as_deref(),
306        &remembered,
307        offered.as_ref(),
308        now,
309    )
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::service::Permit;
316    use crate::state::Account;
317    use crate::time::{Clock, FixedClock};
318    use crate::usage::{Source, Window};
319    use serde_json::json;
320    use std::sync::Arc;
321
322    const NOW: i64 = 1_789_935_000;
323    const HOUR: i64 = 3_600;
324    const DAY: i64 = 86_400;
325
326    fn state() -> State {
327        let account = |label: &str| Account {
328            last_used_at: None,
329            label: label.into(),
330            id: format!("{label}-uuid"),
331            account_uuid: format!("{label}-uuid"),
332            email: format!("{label}@example.com"),
333            detail: crate::state::Detail::Claude {
334                organization_uuid: "o".into(),
335                oauth_account: json!({}),
336            },
337            parked: None,
338        };
339        State {
340            accounts: vec![account("work"), account("personal"), account("side")],
341            ..State::default()
342        }
343    }
344
345    fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
346        timed((five, resets_at), (week, resets_at), observed_at)
347    }
348
349    /// A reading of the five-hour and weekly limits, each as its share and when it resets.
350    fn timed(five_hour: (f64, i64), weekly: (f64, i64), observed_at: i64) -> Snapshot {
351        let window = |kind: &str, (percent, resets_at): (f64, i64)| Window {
352            kind: kind.into(),
353            scope: None,
354            percent,
355            resets_at: Some(resets_at),
356            is_active: false,
357            severity: None,
358            length_seconds: None,
359        };
360        Snapshot {
361            windows: vec![window("session", five_hour), window("weekly_all", weekly)],
362            observed_at: Some(observed_at),
363            account_uuid: None,
364            source: Source::Remembered,
365        }
366    }
367
368    fn shares(five_hour: f64, weekly: f64) -> Shares {
369        Shares {
370            five_hour: Some(five_hour),
371            weekly: Some(weekly),
372        }
373    }
374
375    /// What Claude Code passes a session's status line, with both windows resetting at once.
376    fn session(five_hour: f64, weekly: f64, resets_at: i64) -> Value {
377        passed((five_hour, resets_at), (weekly, resets_at))
378    }
379
380    /// What Claude Code passes a session's status line: each limit's share and when it
381    /// resets.
382    fn passed(five_hour: (f64, i64), weekly: (f64, i64)) -> Value {
383        json!({"rate_limits": {
384            "five_hour": {"used_percentage": five_hour.0, "resets_at": five_hour.1},
385            "seven_day": {"used_percentage": weekly.0, "resets_at": weekly.1}
386        }})
387    }
388
389    /// What `input` offers `work` from a session on it that has just had a response: one
390    /// whose run before named `work` and passed nothing, so everything it passes has moved.
391    fn answered(input: &Value, remembered: &HashMap<String, Snapshot>) -> Option<Snapshot> {
392        let before = Run {
393            account: Some("work-uuid".into()),
394            limits: BTreeMap::new(),
395        };
396        session_snapshot(
397            &run_of(input, Some("work-uuid")),
398            Some(&before),
399            &state(),
400            remembered,
401            NOW,
402        )
403    }
404
405    /// A home of this test's own, removed when the test is done with it.
406    struct Scratch(std::path::PathBuf);
407
408    impl Drop for Scratch {
409        fn drop(&mut self) {
410            let _ = std::fs::remove_dir_all(&self.0);
411        }
412    }
413
414    /// A machine whose Claude Code config names `work-uuid` as signed in.
415    fn machine(name: &str) -> (Context, Scratch) {
416        let root = std::env::temp_dir().join(format!(
417            "pitboard-statusline-{name}-{}-{:?}",
418            std::process::id(),
419            std::thread::current().id()
420        ));
421        let _ = std::fs::remove_dir_all(&root);
422        std::fs::create_dir_all(&root).expect("a scratch home");
423        let ctx = Context::new(root.clone())
424            .with_pitboard_home(root.join(".pitboard"))
425            .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
426        sign_in(&ctx, "work");
427        (ctx, Scratch(root))
428    }
429
430    /// The same machine at another time.
431    fn at(ctx: &Context, now: i64) -> Context {
432        ctx.clone()
433            .with_clock(Arc::new(FixedClock::at(now)) as Arc<dyn Clock>)
434    }
435
436    /// Claude Code's config naming `label`'s account as signed in, as a switch leaves it.
437    fn sign_in(ctx: &Context, label: &str) {
438        std::fs::write(
439            ctx.home().join(".claude.json"),
440            json!({"oauthAccount": {
441                "accountUuid": format!("{label}-uuid"),
442                "emailAddress": format!("{label}@example.com"),
443                "organizationUuid": "o",
444            }})
445            .to_string(),
446        )
447        .expect("a Claude Code config");
448    }
449
450    /// Session `id`'s status line run with `input`, as Claude Code runs it.
451    fn run(ctx: &Context, id: &str, input: &Value) -> StatusLine {
452        let mut input = input.clone();
453        input["session_id"] = json!(id);
454        read(ctx, Some(Permit::for_a_test()), &input.to_string())
455    }
456
457    /// Session `id` starting: its status line runs before it has had a response, with no
458    /// limits to pass.
459    fn open(ctx: &Context, id: &str) {
460        run(ctx, id, &json!({}));
461    }
462
463    fn recorded(ctx: &Context) -> Snapshot {
464        crate::readings::load(ctx)
465            .remove("work-uuid")
466            .expect("a reading")
467    }
468
469    #[test]
470    fn names_the_account_in_use_and_what_every_account_has_left() {
471        let input = json!({"rate_limits": {
472            "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
473            "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
474        }});
475        let remembered = HashMap::from([
476            (
477                "personal-uuid".to_string(),
478                reading(12.0, 40.0, NOW - 60, NOW + 2 * HOUR),
479            ),
480            (
481                "side-uuid".to_string(),
482                reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
483            ),
484        ]);
485        let offered = answered(&input, &remembered);
486        let line = line(
487            &state(),
488            Some("work-uuid"),
489            &remembered,
490            offered.as_ref(),
491            NOW,
492        );
493        assert_eq!(line.current.as_deref(), Some("work"));
494        assert_eq!(line.session, shares(46.4, 70.0));
495        assert_eq!(
496            line.others,
497            [
498                Entry {
499                    label: "personal".into(),
500                    shares: shares(12.0, 40.0),
501                    age: None,
502                },
503                Entry {
504                    label: "side".into(),
505                    shares: shares(0.0, 0.0),
506                    age: Some(3 * 3_600),
507                },
508            ],
509            "a window past its reset counts as reset, and an old reading says how old"
510        );
511    }
512
513    /// The account in use says how fast it is going on each limit, from its numbers and the
514    /// clock: a five-hour limit at 46% ten minutes from its reset is under pace, and a week at
515    /// 70% two days in is over. Nothing is said with nothing to go on.
516    #[test]
517    fn the_account_in_use_says_its_pace_on_each_limit() {
518        let input = json!({"rate_limits": {
519            "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
520            "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 5 * DAY}
521        }});
522        let offered = answered(&input, &HashMap::new());
523        let shown = line(
524            &state(),
525            Some("work-uuid"),
526            &HashMap::new(),
527            offered.as_ref(),
528            NOW,
529        );
530        assert_eq!(shown.pace.five_hour, Some(Standing::Under));
531        assert!(
532            matches!(shown.pace.weekly, Some(Standing::Over { .. })),
533            "{:?}",
534            shown.pace
535        );
536        assert_eq!(
537            line(&state(), None, &HashMap::new(), None, NOW).pace,
538            Paces::default()
539        );
540    }
541
542    #[test]
543    fn what_is_unknown_is_left_unknown_not_zero() {
544        let line = line(&state(), None, &HashMap::new(), None, NOW);
545        assert_eq!(line.current, None);
546        assert_eq!(line.session, Shares::default());
547        assert!(line.others.iter().all(|e| e.shares == Shares::default()));
548        assert_eq!(line.others.len(), 3);
549    }
550
551    /// Claude Code runs this, so the line is about Claude Code's accounts. A Codex account
552    /// whose identity matches the session's is not the account in use, and one that shares
553    /// a label is not an account this session could be switched to.
554    #[test]
555    fn another_tools_accounts_are_not_on_claude_codes_line() {
556        let codex = |label: &str, uuid: &str| Account {
557            last_used_at: None,
558            label: label.into(),
559            id: uuid.into(),
560            account_uuid: uuid.into(),
561            email: format!("{label}@example.com"),
562            detail: crate::state::Detail::Codex {
563                workspace_id: None,
564                plan: None,
565            },
566            parked: None,
567        };
568        let mut s = state();
569        s.accounts.insert(0, codex("shadow", "work-uuid"));
570        s.accounts.push(codex("work", "codex-work"));
571
572        let found = line(&s, Some("work-uuid"), &HashMap::new(), None, NOW);
573        assert_eq!(found.current.as_deref(), Some("work"));
574        let others: Vec<&str> = found.others.iter().map(|e| e.label.as_str()).collect();
575        assert_eq!(others, ["personal", "side"]);
576
577        let found = line(&s, Some("codex-work"), &HashMap::new(), None, NOW);
578        assert_eq!(
579            found.current, None,
580            "a Codex identity names no Claude Code account"
581        );
582    }
583
584    /// The owner's panes on one account: busy ones had recorded 22%·6%, and an idle one
585    /// still held the 20%·5% of its last response. Every pane shows the newer of what its
586    /// own session passed and what any session recorded.
587    #[test]
588    fn the_account_in_use_shows_the_newer_of_the_session_and_what_is_remembered() {
589        let remembered = HashMap::from([(
590            "work-uuid".to_string(),
591            reading(22.0, 6.0, NOW - 60, NOW + 600),
592        )]);
593        let shown = |input: &Value, remembered: &HashMap<String, Snapshot>| {
594            let offered = answered(input, remembered);
595            line(
596                &state(),
597                Some("work-uuid"),
598                remembered,
599                offered.as_ref(),
600                NOW,
601            )
602            .session
603        };
604        assert_eq!(
605            shown(&session(20.0, 5.0, NOW + 600), &remembered),
606            shares(22.0, 6.0)
607        );
608        assert_eq!(
609            shown(&session(25.0, 7.0, NOW + 600), &remembered),
610            shares(25.0, 7.0)
611        );
612
613        // Rerun at its reset, an idle session holds a window that is over; another session
614        // has already recorded the one after it.
615        let next = HashMap::from([(
616            "work-uuid".to_string(),
617            reading(1.0, 6.0, NOW - 60, NOW + 5 * 3_600),
618        )]);
619        assert_eq!(shown(&session(90.0, 5.0, NOW - 1), &next), shares(1.0, 6.0));
620    }
621
622    /// The config names the organisation as well as the person, and the line is about the
623    /// account in that organisation, with the person's other one beside it.
624    #[test]
625    fn the_account_in_use_is_the_one_in_the_organisation_the_config_names() {
626        let (ctx, _scratch) = machine("organisations");
627        let mut accounts = state();
628        accounts.upsert(Account {
629            last_used_at: None,
630            label: "team".into(),
631            id: "work-uuid_team-org".into(),
632            account_uuid: "work-uuid".into(),
633            email: "work@example.com".into(),
634            detail: crate::state::Detail::Claude {
635                organization_uuid: "team-org".into(),
636                oauth_account: json!({}),
637            },
638            parked: None,
639        });
640        crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
641        std::fs::write(
642            ctx.home().join(".claude.json"),
643            json!({"oauthAccount": {
644                "accountUuid": "work-uuid",
645                "emailAddress": "work@example.com",
646                "organizationUuid": "team-org",
647            }})
648            .to_string(),
649        )
650        .expect("a Claude Code config");
651
652        let shown = run(&ctx, "pane", &json!({}));
653        assert_eq!(shown.current.as_deref(), Some("team"));
654        let others: Vec<&str> = shown.others.iter().map(|e| e.label.as_str()).collect();
655        assert_eq!(others, ["work", "personal", "side"]);
656    }
657
658    /// Offered whenever a response moves it, however recently something was recorded, and
659    /// kept only when it is newer. It used to be recorded only when what was remembered was
660    /// a quarter of an hour old, and then over whatever was there.
661    #[test]
662    fn the_sessions_numbers_are_recorded_when_they_are_newer() {
663        let (ctx, _scratch) = machine("records");
664        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
665        crate::readings::remember(
666            &ctx,
667            Permit::for_a_test(),
668            &[("work-uuid".into(), reading(22.0, 6.0, NOW - 60, NOW + 600))],
669        );
670        run(&ctx, "busy", &session(22.0, 6.0, NOW + 600));
671        run(&ctx, "slow", &session(18.0, 4.0, NOW + 600));
672
673        let busy = run(&ctx, "busy", &session(25.0, 7.0, NOW + 600));
674        assert_eq!(busy.session, shares(25.0, 7.0));
675        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
676
677        let slow = run(&ctx, "slow", &session(20.0, 5.0, NOW + 600));
678        assert_eq!(slow.session, shares(25.0, 7.0));
679        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
680    }
681
682    /// A session knows how much is used and when it resets. Which limit the account is
683    /// working against is Anthropic's to say, and the menu bar shows the limit it names:
684    /// recorded as a session's guess, a weekly limit at 70% took the menu bar from the
685    /// five-hour limit that binds.
686    #[test]
687    fn a_session_moves_the_numbers_and_leaves_what_only_anthropic_says() {
688        let (ctx, _scratch) = machine("leaves");
689        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
690        let mut answered = reading(22.0, 6.0, NOW - 60, NOW + 600);
691        answered.windows[0].severity = Some("normal".into());
692        answered.windows[0].is_active = true;
693        crate::readings::remember(
694            &ctx,
695            Permit::for_a_test(),
696            &[("work-uuid".into(), answered)],
697        );
698
699        open(&ctx, "pane");
700        run(&ctx, "pane", &session(25.0, 70.0, NOW + 600));
701        let windows = recorded(&ctx).windows;
702        let said: Vec<(&str, f64, bool)> = windows
703            .iter()
704            .map(|w| (w.kind.as_str(), w.percent, w.is_active))
705            .collect();
706        assert_eq!(
707            said,
708            [("session", 25.0, true), ("weekly_all", 70.0, false)],
709            "under the names it had, and working against the limit it was"
710        );
711        assert_eq!(
712            windows[0].severity, None,
713            "a share Anthropic has not graded"
714        );
715    }
716
717    /// A switch rewrites Claude Code's config at once, and a session left idle goes on
718    /// passing the numbers of its last response, which were the account before's. Recorded
719    /// as the account switched to, their later resets stood over every answer Anthropic gave
720    /// about it until its own windows reset: days, for the weekly limit.
721    #[test]
722    fn a_session_holding_the_account_before_a_switch_is_not_recorded_as_the_one_after() {
723        let (ctx, _scratch) = machine("switched");
724        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
725        let work = |five: f64, weekly: f64, observed_at: i64| {
726            timed((five, NOW + 2 * HOUR), (weekly, NOW + 3 * DAY), observed_at)
727        };
728        crate::readings::remember(
729            &ctx,
730            Permit::for_a_test(),
731            &[("work-uuid".into(), work(10.0, 30.0, NOW - 60))],
732        );
733        let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
734        sign_in(&ctx, "personal");
735        open(&ctx, "pane");
736        run(&ctx, "pane", &personals);
737
738        sign_in(&ctx, "work");
739        for _ in 0..2 {
740            let idle = run(&ctx, "pane", &personals);
741            assert_eq!(idle.current.as_deref(), Some("work"));
742            assert_eq!(
743                idle.session,
744                shares(10.0, 30.0),
745                "the pane shows work's own"
746            );
747            assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
748        }
749
750        let mut answered = work(12.0, 31.0, NOW);
751        answered.source = Source::Live;
752        crate::readings::remember(
753            &ctx,
754            Permit::for_a_test(),
755            &[("work-uuid".into(), answered)],
756        );
757        assert_eq!(
758            shares_of(&recorded(&ctx), NOW),
759            shares(12.0, 31.0),
760            "and what Anthropic says about work is taken"
761        );
762    }
763
764    /// Forgetting an account takes its reading with it, and that reading was all that showed
765    /// its windows to be its own. An idle session still holding its numbers had them recorded
766    /// as the account in use, over what Anthropic went on to say, until those windows reset.
767    #[test]
768    fn a_session_holding_a_forgotten_accounts_numbers_is_not_recorded_as_the_one_in_use() {
769        let (ctx, _scratch) = machine("forgotten");
770        let mut accounts = state();
771        crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
772        let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
773        sign_in(&ctx, "personal");
774        open(&ctx, "pane");
775        run(&ctx, "pane", &personals);
776        let works = timed(
777            (10.0, NOW + 2 * HOUR),
778            (30.0, NOW + 3 * DAY),
779            NOW - 2 * HOUR,
780        );
781        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
782
783        accounts.accounts[0].last_used_at = Some(NOW - HOUR);
784        crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
785        sign_in(&ctx, "work");
786        run(&ctx, "pane", &personals);
787        accounts.accounts.retain(|a| a.label != "personal");
788        crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
789        crate::readings::forget(&ctx, Permit::for_a_test(), "personal-uuid");
790
791        let idle = run(&ctx, "pane", &personals);
792        assert_eq!(
793            idle.session,
794            shares(10.0, 30.0),
795            "the pane shows work's own"
796        );
797        let mut answered = timed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY), NOW);
798        answered.source = Source::Live;
799        crate::readings::remember(
800            &ctx,
801            Permit::for_a_test(),
802            &[("work-uuid".into(), answered)],
803        );
804        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
805    }
806
807    /// Claude Code's own `/login` can sign in an account nobody enrolled and back again, and
808    /// Pitboard has no reading of an account it does not know to tell its windows by. An idle
809    /// session holding that account's numbers had them recorded as the one signed in after.
810    #[test]
811    fn a_session_holding_an_unenrolled_accounts_numbers_is_not_recorded_after_a_login() {
812        let (ctx, _scratch) = machine("unenrolled");
813        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
814        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
815        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
816        let guests = passed((97.0, NOW + 4 * HOUR), (80.0, NOW + 6 * DAY));
817        sign_in(&ctx, "guest");
818        open(&ctx, "pane");
819        run(&ctx, "pane", &guests);
820
821        sign_in(&ctx, "work");
822        for _ in 0..2 {
823            let idle = run(&ctx, "pane", &guests);
824            assert_eq!(
825                idle.session,
826                shares(10.0, 30.0),
827                "the pane shows work's own"
828            );
829        }
830        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
831    }
832
833    /// A session Pitboard has not seen before passes the numbers of whatever response it had
834    /// last, which can be any account's: it may have been open since before a switch. They
835    /// are left out, and what its next response moves is this account's.
836    #[test]
837    fn a_session_seen_for_the_first_time_offers_nothing_until_its_next_response() {
838        let (ctx, _scratch) = machine("first");
839        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
840        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
841        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
842
843        let first = run(
844            &ctx,
845            "pane",
846            &passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY)),
847        );
848        assert_eq!(
849            first.session,
850            shares(10.0, 30.0),
851            "the pane shows work's own"
852        );
853        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
854
855        let next = run(
856            &ctx,
857            "pane",
858            &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
859        );
860        assert_eq!(next.session, shares(12.0, 31.0));
861        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
862    }
863
864    /// Claude Code's config can name another account between two runs of one session, with
865    /// `/login` as well as a switch, and the response the session had in between can have
866    /// been on either. One on the account before whose five-hour window had just reset has a
867    /// window no reading has, so nothing else shows whose it is.
868    #[test]
869    fn what_a_session_passes_as_the_account_named_changes_is_not_recorded() {
870        let (ctx, _scratch) = machine("changed");
871        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
872        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
873        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
874        sign_in(&ctx, "personal");
875        open(&ctx, "pane");
876        run(
877            &ctx,
878            "pane",
879            &passed((100.0, NOW - HOUR), (50.0, NOW + 5 * DAY)),
880        );
881
882        sign_in(&ctx, "work");
883        let changed = run(
884            &ctx,
885            "pane",
886            &passed((2.0, NOW + 4 * HOUR), (51.0, NOW + 5 * DAY)),
887        );
888        assert_eq!(
889            changed.session,
890            shares(10.0, 30.0),
891            "the pane shows work's own"
892        );
893        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
894    }
895
896    /// For half a minute after a switch a session goes on using the account before, and its
897    /// responses are that account's. One nothing has read has no windows to know them by,
898    /// so only the time says so.
899    #[test]
900    fn nothing_is_recorded_while_sessions_are_still_taking_up_a_switch() {
901        let (ctx, _scratch) = machine("adopting");
902        let mut switched = state();
903        switched.accounts[0].last_used_at = Some(NOW - 10);
904        crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
905        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
906        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
907        open(&ctx, "pane");
908
909        let unread = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
910        let shown = run(&ctx, "pane", &unread);
911        assert_eq!(
912            shown.session,
913            shares(10.0, 30.0),
914            "the pane shows work's own"
915        );
916        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
917
918        switched.accounts[0].last_used_at =
919            Some(NOW - i64::from(crate::switch::ADOPTION_CEILING_SECONDS));
920        crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
921        run(
922            &ctx,
923            "pane",
924            &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
925        );
926        assert_eq!(
927            shares_of(&recorded(&ctx), NOW),
928            shares(12.0, 31.0),
929            "and once they have, what they say is"
930        );
931    }
932
933    /// The half minute starts in the second Pitboard puts the account to use, and a session
934    /// can run twice within it: once idle, as the config changes under it, and again with a
935    /// response the account before served it.
936    #[test]
937    fn nothing_is_recorded_in_the_second_the_account_is_put_to_use() {
938        let (ctx, _scratch) = machine("same-second");
939        let mut switched = state();
940        crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
941        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
942        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
943        let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
944        sign_in(&ctx, "personal");
945        run(&at(&ctx, NOW - 60), "pane", &personals);
946
947        switched.accounts[0].last_used_at = Some(NOW);
948        crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
949        sign_in(&ctx, "work");
950        run(&ctx, "pane", &personals);
951        run(
952            &ctx,
953            "pane",
954            &passed((2.0, NOW + 5 * HOUR), (61.0, NOW + 5 * DAY)),
955        );
956        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
957    }
958
959    /// A `/login` in Claude Code leaves Pitboard no time to count from, and a session takes
960    /// as long to follow it as a switch, so its next response can still be the account
961    /// before's with the account after named both times. Where Pitboard has read the
962    /// account before, its windows show whose the response is.
963    #[test]
964    fn a_response_from_the_account_before_a_login_is_known_by_its_windows() {
965        let (ctx, _scratch) = machine("login");
966        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
967        let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
968        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
969        sign_in(&ctx, "personal");
970        open(&ctx, "pane");
971        let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
972        run(&ctx, "pane", &personals);
973
974        sign_in(&ctx, "work");
975        run(&ctx, "pane", &personals);
976        run(
977            &ctx,
978            "pane",
979            &passed((96.0, NOW + 4 * HOUR), (61.0, NOW + 5 * DAY)),
980        );
981        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
982    }
983
984    /// A window whose reset has passed says nothing about now, and a pane left open past it
985    /// can be holding any account's. Recorded, it put the share of a window that was over
986    /// into a reading, where the menu bar and the command line showed it.
987    #[test]
988    fn a_window_past_its_reset_is_not_recorded() {
989        let (ctx, _scratch) = machine("passed");
990        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
991        open(&ctx, "pane");
992        run(
993            &ctx,
994            "pane",
995            &passed((90.0, NOW - 60), (40.0, NOW + 3 * DAY)),
996        );
997        let kinds: Vec<String> = recorded(&ctx).windows.into_iter().map(|w| w.kind).collect();
998        assert_eq!(kinds, ["weekly_all"]);
999    }
1000
1001    /// A session can hold a response from just before its five-hour window reset, one that
1002    /// moved the weekly limit too, and run after the reset. Its numbers then leave the
1003    /// five-hour window out, and the reading lost it: the pane showed no five-hour share
1004    /// rather than none used, `pitboard status` and the menu bar lost the row, and with only
1005    /// the weekly window left to time it by, the app waited a hundred minutes to ask again
1006    /// rather than three.
1007    #[test]
1008    fn a_window_that_resets_stays_in_the_reading_with_nothing_used() {
1009        let (ctx, _scratch) = machine("reset");
1010        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1011        let works = timed((40.0, NOW - 10), (30.0, NOW + 3 * DAY), NOW - HOUR);
1012        crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
1013        let floor = crate::budget::floor_for(Some(&recorded(&ctx)));
1014        run(
1015            &at(&ctx, NOW - 60),
1016            "pane",
1017            &passed((40.0, NOW - 10), (30.0, NOW + 3 * DAY)),
1018        );
1019
1020        let shown = run(
1021            &ctx,
1022            "pane",
1023            &passed((41.0, NOW - 10), (31.0, NOW + 3 * DAY)),
1024        );
1025        assert_eq!(shown.session, shares(0.0, 31.0));
1026        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(0.0, 31.0));
1027        let offline = crate::status::gather_offline(&ctx, &state());
1028        let row = offline
1029            .rows
1030            .iter()
1031            .find(|r| r.account_uuid == "work-uuid")
1032            .and_then(|r| r.usage.as_ref())
1033            .expect("work's numbers");
1034        let kinds: Vec<&str> = row.windows.iter().map(|w| w.kind.as_str()).collect();
1035        assert_eq!(kinds, ["session", "weekly_all"], "the row is kept");
1036        assert_eq!(crate::budget::floor_for(Some(&recorded(&ctx))), floor);
1037    }
1038
1039    /// An account's windows start when it is first used in them, so a reset no other
1040    /// account has is this account's own. It is how a session records the window after one
1041    /// that ran out, before anybody has asked Anthropic.
1042    #[test]
1043    fn a_session_still_records_its_own_accounts_next_window() {
1044        let (ctx, _scratch) = machine("next");
1045        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1046        crate::readings::remember(
1047            &ctx,
1048            Permit::for_a_test(),
1049            &[
1050                (
1051                    "work-uuid".into(),
1052                    timed((100.0, NOW - 60), (40.0, NOW + 3 * DAY), NOW - HOUR),
1053                ),
1054                (
1055                    "personal-uuid".into(),
1056                    timed((20.0, NOW + 4 * HOUR), (50.0, NOW + 5 * DAY), NOW - 60),
1057                ),
1058            ],
1059        );
1060        run(
1061            &ctx,
1062            "pane",
1063            &passed((100.0, NOW - 60), (40.0, NOW + 3 * DAY)),
1064        );
1065        let shown = run(
1066            &ctx,
1067            "pane",
1068            &passed((2.0, NOW + 5 * HOUR), (41.0, NOW + 3 * DAY)),
1069        );
1070        assert_eq!(shown.session, shares(2.0, 41.0));
1071        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(2.0, 41.0));
1072    }
1073
1074    /// Two accounts' windows can reset within a minute of each other. When the account in
1075    /// use has that window too, the session's numbers can be its own, and they are offered.
1076    #[test]
1077    fn a_window_both_accounts_have_is_still_offered() {
1078        let (ctx, _scratch) = machine("coincident");
1079        crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1080        crate::readings::remember(
1081            &ctx,
1082            Permit::for_a_test(),
1083            &[
1084                (
1085                    "work-uuid".into(),
1086                    timed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60),
1087                ),
1088                (
1089                    "personal-uuid".into(),
1090                    timed((50.0, NOW + 2 * HOUR + 30), (60.0, NOW + 5 * DAY), NOW - 60),
1091                ),
1092            ],
1093        );
1094        run(
1095            &ctx,
1096            "pane",
1097            &passed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY)),
1098        );
1099        let shown = run(
1100            &ctx,
1101            "pane",
1102            &passed((25.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
1103        );
1104        assert_eq!(shown.session, shares(25.0, 31.0));
1105        assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 31.0));
1106    }
1107}