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