1 import Base 2 import ./json.bend as Json 3 import ./clock.bend as Clock 4 import ./poll.bend as Poll 5 import ./view.bend as View 6 7 # The human view of a usage reading (switcher.py _format_usage_lines and 8 # _usage_entry_lines), without colour: NO_COLOR is what this prints always. 9 10 def sel_n(c: Bool, +x: Nat, +y: Nat) -> Nat: 11 match c: 12 case True{}: 13 x 14 case False{}: 15 y 16 17 def sel_l(c: Bool, x: List<&2, String>, y: List<&2, String>) -> List<&2, String>: 18 match c: 19 case True{}: 20 x 21 case False{}: 22 y 23 24 def sp(+n: Nat) -> String: 25 String.repeat(" ", n) 26 27 def padl(+s: String, +w: Nat) -> String: 28 sp(Nat.sub(w, String.length(s))) ++ s 29 30 def padr(+s: String, +w: Nat) -> String: 31 s ++ sp(Nat.sub(w, String.length(s))) 32 33 # `{pct:>3.0f}`: tenths rounded to a whole percent, halves to even, width 3. 34 def whole.pick(+w0: U32, +r: U32) -> U32: 35 Clock.sel(U32.is_gt(r, 5), (w0 + 1 : U32), Clock.sel(U32.is_eq(r, 5), (w0 + (w0 % 2) : U32), w0)) 36 37 def whole(+t: U32) -> U32: 38 whole.pick((t / 10 : U32), (t % 10 : U32)) 39 40 def pct_text(+t: U32) -> String: 41 padl(U32.show(whole(t)) ++ "%", 4n) 42 43 def money.put(comma: Bool, +c: Char, acc: String) -> String: 44 match comma: 45 case True{}: 46 SCon{c, SCon{',', acc}} 47 case False{}: 48 SCon{c, acc} 49 50 def money.group(r: String, +n: Nat, acc: String) -> String: 51 match r: 52 case SNil{}: 53 acc 54 case SCon{+c, +t}: 55 money.group(t, 1n+n, money.put(Nat.is_gt(n, 0n) && Nat.is_eq(Nat.mod(n, 3n), 0n), c, acc)) 56 57 def money.commas(+d: String) -> String: 58 money.group(String.reverse(d), 0n, "") 59 60 def money.fmt(+c: U32) -> String: 61 money.commas(U32.show((c / 100 : U32))) ++ "." ++ Clock.pad2((c % 100 : U32)) 62 63 def money.cents(+i: String, +f: String) -> String: 64 money.fmt(Maybe.default(&2, U32, Maybe.bind(&2, U32, U32, U32.read(i), x => Maybe.bind(&2, U32, U32, Clock.num(f ++ "000", 0n, 2n), y => Maybe.bind(&2, U32, U32, Clock.num(f ++ "000", 2n, 1n), z => Some{(x * 100 + y + Clock.sel(U32.is_ge(z, 5), 1, 0) : U32)}))), 0)) 65 66 def money.parts(ps: List<&2, String>) -> String: 67 match ps: 68 case Con{i, Con{f, Nil{}}}: 69 money.cents(i, f) 70 case Con{i, Nil{}}: 71 money.cents(i, "") 72 case _: 73 "0.00" 74 75 # `${:,.2f}` of a JSON number. 76 def money(j: Json.Json) -> String: 77 match j: 78 case Json.JNum{t}: 79 money.parts(String.split(t, '.')) 80 case _: 81 "0.00" 82 83 type Line is Data: 84 Line{label: String, body: String} 85 86 def tenths_of(+w: Json.Json) -> U32: 87 Maybe.default(&2, U32, Poll.tenths.json(View.jv("pct", w)), 0) 88 89 def marker.ahead(m: Maybe<&2, View.Pace>) -> String: 90 match m: 91 case Some{View.Pace{e, a, eta, last}}: 92 Clock.sel.s(a, " (ahead of pace)", "") 93 case None{}: 94 "" 95 96 def body.cell(+pct: U32, c: Maybe<&2, View.Cell>, +marker: String) -> String: 97 match c: 98 case Some{View.Cell{cd, ck}}: 99 pct_text(pct) ++ " resets " ++ padr(ck, 12n) ++ " in " ++ cd ++ marker 100 case None{}: 101 pct_text(pct) ++ marker 102 103 def window_line(+label: String, +w: Json.Json, +marker: String, +now: U32, +off: U32) -> Line: 104 Line{label, body.cell(tenths_of(w), View.fresh(w, now, off), marker)} 105 106 def spend.body(c: Maybe<&2, View.Cell>, +pct: U32, +used: String, +limit: String) -> String: 107 match c: 108 case Some{View.Cell{cd, ck}}: 109 pct_text(pct) ++ " resets " ++ padr(ck, 12n) ++ " $" ++ used ++ " / $" ++ limit 110 case None{}: 111 pct_text(pct) ++ " $" ++ used ++ " / $" ++ limit 112 113 def spend_line(+s: Json.Json, +now: U32, +off: U32) -> Line: 114 Line{"$$", spend.body(View.fresh(s, now, off), tenths_of(s), money(View.jv("used", s)), money(View.jv("limit", s)))} 115 116 def cons_m(m: Maybe<&2, Line>, rest: List<&2, Line>) -> List<&2, Line>: 117 match m: 118 case Some{l}: 119 l <> rest 120 case None{}: 121 rest 122 123 def win_m(+label: String, m: Maybe<&2, Json.Json>, weekly: Bool, +fetched: Maybe<&2, U32>, +now: U32, +off: U32) -> Maybe<&2, Line>: 124 match m: 125 case None{}: 126 None{} 127 case Some{+w}: 128 Some{window_line(label, w, Clock.sel.s(weekly, marker.ahead(View.pace_of(w, fetched)), ""), now, off)} 129 130 def spend_m(m: Maybe<&2, Json.Json>, +now: U32, +off: U32) -> Maybe<&2, Line>: 131 match m: 132 case Some{s}: 133 Some{spend_line(s, now, off)} 134 case None{}: 135 None{} 136 137 def scoped_marker(+w: Json.Json, +fetched: Maybe<&2, U32>) -> String: 138 Clock.sel.s(U32.is_ge(tenths_of(w), 1000), " (!)", marker.ahead(View.pace_of(w, fetched))) 139 140 def scoped_lines(ws: List<&2, Json.Json>, +fetched: Maybe<&2, U32>, +now: U32, +off: U32) -> List<&2, Line>: 141 match ws: 142 case Nil{}: 143 [] 144 case Con{+w, t}: 145 window_line(Maybe.default(&2, String, Json.as_string(View.jv("name", w)), "?"), w, scoped_marker(w, fetched), now, off) <> scoped_lines(t, fetched, now, off) 146 147 def usage_rows(+u: Json.Json, +fetched: Maybe<&2, U32>, +now: U32, +off: U32) -> List<&2, Line>: 148 cons_m(spend_m(Json.get("spend", u), now, off), 149 cons_m(win_m("5h", Json.get("five_hour", u), False{}, fetched, now, off), 150 cons_m(win_m("7d", Json.get("seven_day", u), True{}, fetched, now, off), 151 scoped_lines(Json.to_list(View.jv("scoped", u)), fetched, now, off)))) 152 153 def width(ls: List<&2, Line>, +w: Nat) -> Nat: 154 match ls: 155 case Nil{}: 156 1n+w 157 case Con{Line{+l, b}, t}: 158 width(t, sel_n(Nat.is_gt(String.length(l), w), String.length(l), w)) 159 160 def fmt_lines(ls: List<&2, Line>, +w: Nat) -> List<&2, String>: 161 match ls: 162 case Nil{}: 163 [] 164 case Con{Line{l, b}, t}: 165 (padr(l ++ ":", w) ++ " " ++ b) <> fmt_lines(t, w) 166 167 def age_text(+secs: U32) -> String: 168 Clock.sel.s(U32.is_lt(secs, 60), "just now", Clock.sel.s(U32.is_lt(secs, 3600), U32.show((secs / 60 : U32)) ++ "m ago", 169 Clock.sel.s(U32.is_lt(secs, 86400), U32.show((secs / 3600 : U32)) ++ "h ago", U32.show((secs / 86400 : U32)) ++ "d ago"))) 170 171 def tree(ls: List<&2, String>, +last: Nat, +i: Nat) -> List<&2, String>: 172 match ls: 173 case Nil{}: 174 [] 175 case Con{h, t}: 176 (Clock.sel.s(Nat.is_eq(i, last), "\u{2514} ", "\u{251C} ") ++ h) <> tree(t, last, 1n+i) 177 178 def last_idx(ls: List<&2, String>) -> Nat: 179 Nat.sub(List.length(&2, String, ls), 1n) 180 181 def append_last(ls: List<&2, String>, +note: String) -> List<&2, String>: 182 match ls: 183 case Nil{}: 184 [] 185 case Con{h, Nil{}}: 186 [h ++ note] 187 case Con{h, Con{h2, t2}}: 188 h <> append_last(h2 <> t2, note) 189 190 def with_age(ls: List<&2, String>, +note: String) -> List<&2, String>: 191 append_last(ls, note) 192 193 def note.pick(old: Bool, +a: U32, ls: List<&2, String>) -> List<&2, String>: 194 match old: 195 case True{}: 196 with_age(ls, " \u{B7} " ++ age_text(a)) 197 case False{}: 198 ls 199 200 def age_note(+age: Maybe<&2, U32>, ls: List<&2, String>) -> List<&2, String>: 201 match age: 202 case Some{+a}: 203 note.pick(U32.is_gt(a, 180), a, ls) 204 case None{}: 205 ls 206 207 def measured2(+ls: List<&2, String>, +age: Maybe<&2, U32>) -> List<&2, String>: 208 tree(age_note(age, ls), last_idx(ls), 0n) 209 210 def measured(+u: Json.Json, +fetched: Maybe<&2, U32>, +age: Maybe<&2, U32>, +now: U32, +off: U32) -> List<&2, String>: 211 measured2(fmt_lines(usage_rows(u, fetched, now, off), width(usage_rows(u, fetched, now, off), 0n)), age) 212 213 def error_note(+k: String) -> String: 214 Clock.sel.s(String.eq(k, "consume-busy"), "another cswap surface holds the slot - retries next pass", 215 Clock.sel.s(String.eq(k, "invalid_client"), "cswap's OAuth client was rejected - systemic, not this account", 216 Clock.sel.s(String.eq(k, "store-unmirrored"), "CLAUDE_SECURESTORAGE_CONFIG_DIR set - unset it or run from a normal shell", k))) 217 218 def unavailable(le: Maybe<&2, String>) -> List<&2, String>: 219 match le: 220 case Some{k}: 221 ["usage unavailable (" ++ error_note(k) ++ ")"] 222 case None{}: 223 ["usage unavailable"] 224 225 def sentinel_note(+s: String) -> String: 226 Clock.sel.s(String.eq(s, "token_expired"), "token expired - refresh deferred this pass; retries automatically", 227 Clock.sel.s(String.eq(s, "foreign_credential"), "live credential belongs to another account - a switch repairs it", 228 Clock.sel.s(String.eq(s, "api_key"), "API key (no quota)", 229 Clock.sel.s(String.eq(s, "keychain_unavailable"), "keychain unavailable - locked or in use; try again", 230 Clock.sel.s(String.eq(s, "relogin_required"), "re-login needed - refresh token dead; log in with Claude Code, then run: cswap add", 231 Clock.sel.s(String.eq(s, "no_credentials"), "no credentials", s)))))) 232 233 def last_seen2(b: Maybe<&2, U32>, +age: Maybe<&2, U32>) -> List<&2, String>: 234 match b age: 235 case Some{p} Some{a}: 236 ["\u{2514} last seen " ++ U32.show(whole(p)) ++ "% used \u{B7} " ++ age_text(a)] 237 case _ _: 238 [] 239 240 def last_seen(+lg: Json.Json, +fetched: Maybe<&2, U32>, +age: Maybe<&2, U32>, +off: U32, models: List<&2, String>) -> List<&2, String>: 241 last_seen2(Poll.binding(lg, []), age) 242 243 def sentinel_lines(+s: String, +lg: Json.Json, +fetched: Maybe<&2, U32>, +age: Maybe<&2, U32>, +off: U32) -> List<&2, String>: 244 sentinel_note(s) <> sel_l(String.eq(s, "api_key"), [], last_seen(lg, fetched, age, off, []))