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. So this reads only files: no keychain, no
6//! network, and nothing written. The other accounts show pitboard's last reading of them,
7//! with its age once that is worth knowing.
8
9use crate::context::Context;
10use crate::state::State;
11use crate::usage::Snapshot;
12use serde_json::Value;
13use std::collections::HashMap;
14
15/// Older than this, a remembered reading shows its age.
16const FRESH_FOR: i64 = 15 * 60;
17
18/// The share of the five-hour and weekly limits already used, when known.
19#[derive(Debug, Clone, Copy, Default, PartialEq)]
20pub struct Shares {
21    pub five_hour: Option<f64>,
22    pub weekly: Option<f64>,
23}
24
25/// An enrolled account other than the one in use.
26#[derive(Debug, PartialEq)]
27pub struct Entry {
28    pub label: String,
29    pub shares: Shares,
30    /// Seconds since this was measured, once that is worth knowing.
31    pub age: Option<i64>,
32}
33
34#[derive(Debug, PartialEq)]
35pub struct StatusLine {
36    /// The account Claude Code's config names, or `None` when it is not enrolled.
37    pub current: Option<String>,
38    /// What Claude Code passed for the session's own account.
39    pub session: Shares,
40    pub others: Vec<Entry>,
41}
42
43/// A window whose reset has passed since counts as reset.
44fn used_share(percent: f64, resets_at: Option<i64>, now: i64) -> f64 {
45    if resets_at.is_some_and(|at| at <= now) {
46        0.0
47    } else {
48        percent
49    }
50}
51
52/// What Claude Code passes for the session's own account, under `rate_limits`.
53fn session_shares(input: &Value, now: i64) -> Shares {
54    let share = |window: &str| {
55        let w = input.get("rate_limits")?.get(window)?;
56        Some(used_share(
57            w.get("used_percentage")?.as_f64()?,
58            w.get("resets_at").and_then(Value::as_i64),
59            now,
60        ))
61    };
62    Shares {
63        five_hour: share("five_hour"),
64        weekly: share("seven_day"),
65    }
66}
67
68/// A remembered reading.
69fn remembered_shares(snapshot: &Snapshot, now: i64) -> Shares {
70    let share = |kinds: &[&str]| {
71        snapshot
72            .windows
73            .iter()
74            .find(|w| w.scope.is_none() && kinds.contains(&w.kind.as_str()))
75            .map(|w| used_share(w.percent, w.resets_at, now))
76    };
77    Shares {
78        five_hour: share(&["session", "five_hour"]),
79        weekly: share(&["weekly_all", "seven_day"]),
80    }
81}
82
83/// `signed_in` is the account Claude Code's config names.
84fn line(
85    input: &Value,
86    state: &State,
87    signed_in: Option<&str>,
88    remembered: &HashMap<String, Snapshot>,
89    now: i64,
90) -> StatusLine {
91    let current = signed_in.and_then(|uuid| state.by_uuid(uuid));
92    let others = state
93        .accounts
94        .iter()
95        .filter(|a| current.is_none_or(|c| c.account_uuid != a.account_uuid))
96        .map(|account| {
97            let reading = remembered.get(&account.account_uuid);
98            Entry {
99                label: account.label.clone(),
100                shares: reading.map_or_else(Shares::default, |r| remembered_shares(r, now)),
101                age: reading
102                    .and_then(|r| r.observed_at)
103                    .map(|at| now - at)
104                    .filter(|age| *age > FRESH_FOR),
105            }
106        })
107        .collect();
108    StatusLine {
109        current: current.map(|a| a.label.clone()),
110        session: session_shares(input, now),
111        others,
112    }
113}
114
115/// Reads Claude Code's session JSON. Never fails: a status bar has nowhere to show an
116/// error, so whatever cannot be read is left out.
117pub fn read(ctx: &Context, input: &str) -> StatusLine {
118    let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
119    let state = crate::state::load(ctx).unwrap_or_default();
120    let signed_in = crate::claude::load_config(ctx)
121        .ok()
122        .as_ref()
123        .and_then(crate::claude::identity)
124        .map(|id| id.account_uuid);
125    line(
126        &input,
127        &state,
128        signed_in.as_deref(),
129        &crate::readings::load(ctx),
130        crate::time::now(),
131    )
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::state::Account;
138    use crate::usage::{Source, Window};
139    use serde_json::json;
140
141    const NOW: i64 = 1_789_935_000;
142
143    fn state() -> State {
144        let account = |label: &str| Account {
145            label: label.into(),
146            account_uuid: format!("{label}-uuid"),
147            email: format!("{label}@example.com"),
148            organization_uuid: "o".into(),
149            oauth_account: json!({}),
150            parked: None,
151        };
152        State {
153            accounts: vec![account("work"), account("personal"), account("side")],
154            ..State::default()
155        }
156    }
157
158    fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
159        let window = |kind: &str, percent| Window {
160            kind: kind.into(),
161            scope: None,
162            percent,
163            resets_at: Some(resets_at),
164            is_active: false,
165        };
166        Snapshot {
167            windows: vec![window("session", five), window("weekly_all", week)],
168            observed_at: Some(observed_at),
169            account_uuid: None,
170            source: Source::Remembered,
171        }
172    }
173
174    fn shares(five_hour: f64, weekly: f64) -> Shares {
175        Shares {
176            five_hour: Some(five_hour),
177            weekly: Some(weekly),
178        }
179    }
180
181    #[test]
182    fn names_the_account_in_use_and_what_every_account_has_left() {
183        let input = json!({"rate_limits": {
184            "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
185            "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
186        }});
187        let remembered = HashMap::from([
188            (
189                "personal-uuid".to_string(),
190                reading(12.0, 40.0, NOW - 60, NOW + 600),
191            ),
192            (
193                "side-uuid".to_string(),
194                reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
195            ),
196        ]);
197        let line = line(&input, &state(), Some("work-uuid"), &remembered, NOW);
198        assert_eq!(line.current.as_deref(), Some("work"));
199        assert_eq!(line.session, shares(46.4, 70.0));
200        assert_eq!(
201            line.others,
202            [
203                Entry {
204                    label: "personal".into(),
205                    shares: shares(12.0, 40.0),
206                    age: None,
207                },
208                Entry {
209                    label: "side".into(),
210                    shares: shares(0.0, 0.0),
211                    age: Some(3 * 3_600),
212                },
213            ],
214            "a window past its reset counts as reset, and an old reading says how old"
215        );
216    }
217
218    #[test]
219    fn what_is_unknown_is_left_unknown_not_zero() {
220        let line = line(&json!({}), &state(), None, &HashMap::new(), NOW);
221        assert_eq!(line.current, None);
222        assert_eq!(line.session, Shares::default());
223        assert!(line.others.iter().all(|e| e.shares == Shares::default()));
224        assert_eq!(line.others.len(), 3);
225    }
226}