@neosh/usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/main.ts +1621 -0
  2. package/package.json +21 -0
  3. package/plugin.toml +4 -0
package/main.ts ADDED
@@ -0,0 +1,1621 @@
1
+ /**
2
+ * What this is costing, on three timescales.
3
+ *
4
+ * **Context** is this request: how full the window was on the last one, and it is the number that
5
+ * changes what you do next — at 90% the useful move is a new conversation, and finding that out
6
+ * from a truncation error is finding out too late.
7
+ *
8
+ * **Tokens** is this conversation: a running total, about cost rather than about room.
9
+ *
10
+ * **The plan** is your week. A rolling allowance the vendor enforces and will not itemise, which is
11
+ * the only one of the three that can stop you working — and the only one that is *reported* rather
12
+ * than counted. It goes at the foot of the sidebar, because unlike the other two it is not about
13
+ * the conversation you are in: it is the same number whichever one you open, and it is the thing
14
+ * you want to have seen before starting something long.
15
+ *
16
+ * They are deliberately never added together, and never drawn as one bar. A conversation that has
17
+ * spent 400k tokens across twenty turns is not 400k tokens *long*; a percentage of an opaque
18
+ * allowance is not a token count; and a footer that implied either would be wrong in the direction
19
+ * that makes people plan around a number that means nothing.
20
+ *
21
+ * # Everything here is ordinary API
22
+ *
23
+ * The strip is a `sidebar.section` contribution, so it can be turned off and something else put
24
+ * there. The panel is a buffer with a `kind`, so its keys are bindable and a plugin can add rows to
25
+ * it. Every key is a named command, so `^Z` lists them and `init.ts` can move them. Nothing in this
26
+ * file calls anything a third party could not.
27
+ */
28
+
29
+ import type {
30
+ Brand,
31
+ CredentialInfo,
32
+ ModelEntry,
33
+ Neosh,
34
+ PluginContext,
35
+ QuotaSnapshot,
36
+ QuotaWindow,
37
+ SessionInfo,
38
+ UsageBucket,
39
+ UsageHistory,
40
+ UsageResolution,
41
+ ViewId,
42
+ } from "@neosh/api";
43
+ import { byteLength } from "@neosh/api";
44
+ import {
45
+ compact,
46
+ CursoredList,
47
+ elapsed,
48
+ type ListRow,
49
+ meter,
50
+ money,
51
+ onTick,
52
+ } from "@neosh/api/ui";
53
+
54
+ const NS = "usage";
55
+ /** What the panel's buffer says it is. Everything a third party binds or finds hangs off this. */
56
+ const KIND = "neosh.usage";
57
+ /** Where somebody else's rows go, in the panel. */
58
+ const POINT_ROW = "usage.row";
59
+
60
+ export async function activate(ctx: PluginContext) {
61
+ await declareOptions(ctx.neosh);
62
+ await installFooter(ctx);
63
+ await installStrip(ctx);
64
+ await installPanel(ctx);
65
+ }
66
+
67
+ async function declareOptions(neosh: Neosh) {
68
+ await neosh.opt.declare({
69
+ name: "usage.show_tokens",
70
+ type: { type: "bool" },
71
+ default: true,
72
+ description: "Show the running token total beside the context meter.",
73
+ });
74
+ await neosh.opt.declare({
75
+ name: "usage.sidebar",
76
+ type: { type: "bool" },
77
+ default: true,
78
+ description: "Show what the plan has left at the foot of the sidebar.",
79
+ });
80
+ await neosh.opt.declare({
81
+ name: "usage.sidebar.style",
82
+ type: { type: "str" },
83
+ default: "one",
84
+ description:
85
+ "How much of the plan the sidebar spends rows on. `one` is a row per account — the limit " +
86
+ "that would refuse the next request, plus any other that is already critical. `windows` is " +
87
+ "a row per limit. `full` adds the account row and the sentence under it. `<Tab>` on a plan " +
88
+ "row steps through them, and `^L` is the whole of it whichever is set.",
89
+ });
90
+ await neosh.opt.declare({
91
+ name: "usage.poll",
92
+ type: { type: "bool" },
93
+ default: true,
94
+ description:
95
+ "Ask the provider what the plan has left while nothing is running. Reads the vendor CLI's " +
96
+ "own login to do it; off, the gauges show what the last turn reported and say how old that is.",
97
+ });
98
+ await neosh.opt.declare({
99
+ name: "usage.warn_at",
100
+ type: { type: "int" },
101
+ default: 90,
102
+ description: "Say something the first time an allowance passes this percentage. 0 to never.",
103
+ });
104
+ await neosh.opt.declare({
105
+ name: "usage.days",
106
+ type: { type: "int" },
107
+ default: 30,
108
+ description: "How many days of history the usage panel opens on.",
109
+ });
110
+ }
111
+
112
+ /* -------------------------------------------------------------------------- */
113
+ /* The strip at the foot of the sidebar */
114
+ /* -------------------------------------------------------------------------- */
115
+
116
+ /**
117
+ * What the plan has left, as rows somebody else draws.
118
+ *
119
+ * Contributed rather than drawn, which is what makes it removable: `usage.sidebar = false` takes it
120
+ * out, a sidebar of your own that never reads `sidebar.section` never shows it, and a plugin that
121
+ * wants these numbers somewhere else calls `quota.list()` and draws its own.
122
+ *
123
+ * One row per window, because there is usually more than one and they say different things: a
124
+ * session limit that is nearly full comes back in an hour, and a weekly one that is nearly full
125
+ * does not. Collapsing them to "the worst" would hide exactly that difference.
126
+ */
127
+ async function installStrip({ neosh, subscriptions }: PluginContext) {
128
+ /** What was last contributed, so an unchanged redraw is not a round trip. */
129
+ let drawn = "";
130
+ /** Windows already complained about, keyed by instance and window. Cleared when one resets. */
131
+ const warned = new Set<string>();
132
+ /**
133
+ * Who each instance is, for the account row.
134
+ *
135
+ * Cached because this runs on a tick and the answer changes when somebody signs in, not several
136
+ * times a second. Refreshed on the events that can change it rather than polled.
137
+ */
138
+ let who: CredentialInfo[] = [];
139
+ const reload = async () => {
140
+ who = await neosh.agent.credentials().catch(() => [] as CredentialInfo[]);
141
+ };
142
+
143
+ const refresh = async () => {
144
+ const on = (await neosh.opt.get<boolean>("usage.sidebar").catch(() => true)) ?? true;
145
+ if (!on) {
146
+ if (drawn !== "") {
147
+ drawn = "";
148
+ await neosh.ext.remove("sidebar.section", "plan").catch(() => {});
149
+ }
150
+ return;
151
+ }
152
+ const [quotas, ascii, nerd, width, style] = await Promise.all([
153
+ neosh.quota.list().catch(() => [] as QuotaSnapshot[]),
154
+ neosh.opt.get<boolean>("ui.ascii_only").catch(() => false),
155
+ neosh.opt.get<boolean>("ui.nerd_font").catch(() => false),
156
+ // The column this has to fit in. Read rather than assumed: the sidebar clips a contributed
157
+ // row to its own width and then draws `right` over the top of whatever survived, so a row
158
+ // built to a guessed width does not wrap — it collides, and the countdown lands on the last
159
+ // letter of the label.
160
+ neosh.opt.get<number>("sidebar.width").catch(() => 34),
161
+ neosh.opt.get<string>("usage.sidebar.style").catch(() => "one"),
162
+ ]);
163
+ // An account we cannot name yet. Reaching a quota for an instance that is not in the credential
164
+ // list means the list is stale — a provider registered late, or somebody has just signed in —
165
+ // and drawing `claude-cli` where `Claude` belongs for the rest of the session is the failure
166
+ // this avoids. Once per staleness, not once per frame: `who` is replaced before the next one.
167
+ if (quotas.some((q) => !who.some((c) => c.instance === q.instance))) await reload();
168
+ const glyphs = { ascii: ascii ?? false, nerd: nerd ?? false };
169
+ const item = section(quotas, who, glyphs, width ?? 34, Date.now() / 1000, styleOf(style));
170
+ // Compared as JSON rather than by identity: this runs on a tick, and re-contributing the same
171
+ // rows makes the sidebar rebuild its whole list — cursor, scroll and all — several times a
172
+ // second, for no change anybody can see.
173
+ const key = JSON.stringify(item);
174
+ if (key === drawn) return;
175
+ drawn = key;
176
+ if (!item) {
177
+ await neosh.ext.remove("sidebar.section", "plan").catch(() => {});
178
+ return;
179
+ }
180
+ await neosh.ext.contribute("sidebar.section", "plan", item, { priority: -10 });
181
+ };
182
+
183
+ // Stepping the density, and taking the strip off the column altogether. Commands rather than
184
+ // config-file lines: `^K` runs them, `^Z` lists the key, and `init.ts` moves it — the same way
185
+ // every other verb here works. They write the *setting*, so `config.toml` decides where you
186
+ // start and the key decides where you are.
187
+ subscriptions.push(
188
+ await neosh.cmd.register(`${NS}.sidebar.cycle`, async () => {
189
+ const now = styleOf(await neosh.opt.get<string>("usage.sidebar.style").catch(() => "one"));
190
+ const next = STYLES[(STYLES.indexOf(now) + 1) % STYLES.length] ?? "one";
191
+ await neosh.opt.set("usage.sidebar.style", next);
192
+ }, { desc: "How much of the plan the sidebar shows" }),
193
+ );
194
+ subscriptions.push(
195
+ await neosh.cmd.register(`${NS}.sidebar.toggle`, async () => {
196
+ const on = (await neosh.opt.get<boolean>("usage.sidebar").catch(() => true)) ?? true;
197
+ await neosh.opt.set("usage.sidebar", !on);
198
+ }, { desc: "Show or hide the plan at the foot of the sidebar" }),
199
+ );
200
+ // And a key on the rows themselves. `<Tab>` because that is what opens and folds a thing you are
201
+ // standing on everywhere else in neosh, and `on: "custom"` so it is a verb about *these* rows
202
+ // rather than one advertised over every conversation in the panel.
203
+ await neosh.ext.contribute("sidebar.action", "plan.detail", {
204
+ key: "<Tab>",
205
+ label: "plan detail",
206
+ command: `${NS}.sidebar.cycle`,
207
+ on: "custom",
208
+ }).catch(() => {});
209
+ subscriptions.push({
210
+ dispose: () => void neosh.ext.remove("sidebar.action", "plan.detail").catch(() => {}),
211
+ });
212
+
213
+ await reload();
214
+ await refresh();
215
+ subscriptions.push(neosh.quota.onChange((s) => {
216
+ void refresh();
217
+ void announce(neosh, s, warned);
218
+ }));
219
+ subscriptions.push(neosh.opt.onChange((e) => {
220
+ if (
221
+ e.name === "usage.sidebar" || e.name === "usage.sidebar.style" ||
222
+ e.name === "ui.ascii_only" || e.name === "ui.nerd_font" || e.name === "sidebar.width"
223
+ ) void refresh();
224
+ }));
225
+ // A selection change is the cheap signal that the provider list may have moved — signing in is
226
+ // what puts a name and a mark on an account that had neither, and it is usually followed by
227
+ // choosing something on it. `refresh` also reloads on its own when a quota turns up for an
228
+ // instance it cannot name, so a sign-in nobody selected after is caught within a tick either way.
229
+ subscriptions.push(neosh.agent.onSelectionChange(() => void reload().then(refresh)));
230
+ // A countdown is the only thing here that moves without anything happening, and it moves once a
231
+ // minute. The tick is already running for the working line; `refresh` bails on an unchanged
232
+ // render, so this costs a string compare.
233
+ subscriptions.push(onTick(() => void refresh()));
234
+ }
235
+
236
+ /** A contributed sidebar row, in the shape `sidebar.section` takes. */
237
+ interface StripRow {
238
+ text: string;
239
+ hl?: string;
240
+ /** Pieces of the row in their own group, over the top of `hl`: the provider's mark. */
241
+ spans?: Array<{ from: number; to: number; hl: string }>;
242
+ right?: { text: string; hl?: string };
243
+ command?: string;
244
+ }
245
+
246
+ /**
247
+ * What the sidebar is handed.
248
+ *
249
+ * `null` when there is nothing to say — no rows rather than a heading over an empty block, because
250
+ * a section that is always there and usually empty is a section people stop looking at.
251
+ *
252
+ * # Shape
253
+ *
254
+ * An account, then its limits, then one sentence. The account row is the part that used to be
255
+ * missing entirely: three bars and no name is a column that answers "how much is left" without
256
+ * ever saying *of what*, and on a machine with both a Claude plan and a Codex one it was two sets
257
+ * of identical-looking bars stacked on top of each other. It carries the same mark and the same
258
+ * brand colour the model picker uses, so the thing you chose in `^P` and the thing being spent
259
+ * here are visibly one thing.
260
+ *
261
+ * The sentence is the other half. A bar that fills as an allowance is *spent* and a countdown with
262
+ * no verb on it are both readable and neither is unambiguous — `12%` and `3h` do not say which way
263
+ * round they go. One line in words, about the limit that actually binds, says both: how much is
264
+ * left, and when it comes back.
265
+ */
266
+ /**
267
+ * How much of the column the plan is allowed to be.
268
+ *
269
+ * A plan with three limits and a name and a sentence under it is five rows, which is most of a
270
+ * short sidebar spent on a number that changes twice an hour. `one` is the answer to the question
271
+ * people actually have — *may I start something long* — and the rest is a keypress away.
272
+ *
273
+ * Ordered, because `<Tab>` steps along them.
274
+ */
275
+ const STYLES = ["one", "windows", "full"] as const;
276
+ type Style = (typeof STYLES)[number];
277
+
278
+ export function styleOf(value: unknown): Style {
279
+ return STYLES.includes(value as Style) ? value as Style : "one";
280
+ }
281
+
282
+ /**
283
+ * The limits worth a row when there is only room for the answer.
284
+ *
285
+ * The one that binds, because it is the one that would refuse the next request — and anything else
286
+ * already critical, because a weekly cap at 94% is news whether or not the session limit is the one
287
+ * about to stop you. Never more than that: this is the summary, and a summary that grows back to
288
+ * the full list is not one.
289
+ */
290
+ function summarised(q: QuotaSnapshot): QuotaWindow[] {
291
+ const first = q.windows.find((w) => w.active) ?? binding(q.windows);
292
+ const out = first ? [first] : [];
293
+ for (const w of q.windows) {
294
+ if (w === first) continue;
295
+ if (w.severity === "critical" || w.severity === "exhausted") out.push(w);
296
+ }
297
+ return out;
298
+ }
299
+
300
+ function section(
301
+ quotas: QuotaSnapshot[],
302
+ who: CredentialInfo[],
303
+ glyphs: { ascii: boolean; nerd: boolean },
304
+ width: number,
305
+ now: number,
306
+ style: Style,
307
+ ) {
308
+ const rows: StripRow[] = [];
309
+ // What the sidebar leaves for the text (its one-column margin at each edge), less the widest
310
+ // countdown and a space to keep it off the label.
311
+ const room = Math.max(14, width - 2 - 5);
312
+ const accounts = quotas.filter((q) => q.windows.length > 0);
313
+
314
+ for (const q of accounts) {
315
+ // Air between accounts. Two of them run together into one block of bars otherwise, and the
316
+ // second account's name reads as another limit belonging to the first. Not in `one`, where an
317
+ // account *is* a row and a blank line between two of them costs more than it separates.
318
+ if (rows.length > 0 && style !== "one") rows.push({ text: "" });
319
+ // Whose allowance this is. Always in `full`; in `windows` only when there is more than one
320
+ // account, since with a single one it is a row that says what the mark on the bar already
321
+ // says; never in `one`, where an account is a row.
322
+ if (style === "full" || (style === "windows" && accounts.length > 1)) {
323
+ rows.push(accountRow(q, who, glyphs, room));
324
+ }
325
+ const windows = style === "one" ? summarised(q) : q.windows;
326
+ const cred = who.find((c) => c.instance === q.instance);
327
+ // The mark goes on the bar itself when there is no account row above it to carry it: a row of
328
+ // brand colour with no brand on it is a coloured bar nobody can attribute.
329
+ const brand = style === "one" ? `${markFor(cred?.brand, glyphs)} ` : "";
330
+ // A `▸` says which limit binds, and is worth two columns only when there is more than one
331
+ // limit on screen to distinguish it from.
332
+ const marker = windows.length > 1 ? MARKER : 0;
333
+ // Widest label first, so every bar in an account starts in the same column — a bar whose left
334
+ // edge moves from row to row cannot be compared with the one above it, which is the only thing
335
+ // a stack of bars is for. In `one` the binding row is labelled by the *account* rather than the
336
+ // window, because `Session` with no account row above it says whose session to nobody, and the
337
+ // name is what two of these rows are told apart by. The rows a critical window adds keep their
338
+ // own labels — they sit under a named one.
339
+ const labels = windows.map((w, i) =>
340
+ style === "one" && i === 0
341
+ ? clip(cred?.display_name || q.instance, Math.max(4, room - cells(brand)))
342
+ : compactLabel(w, room - cells(brand)),
343
+ );
344
+ const labelWidth = Math.min(
345
+ Math.max(...labels.map(cells), 0),
346
+ Math.max(6, room - cells(brand) - marker - BAR_MIN - PCT - 1),
347
+ );
348
+ const bar = Math.max(
349
+ BAR_MIN,
350
+ Math.min(10, room - cells(brand) - marker - labelWidth - PCT - 1),
351
+ );
352
+ // The mark keeps its own colour. The row is graded by severity — that is what a bar is for —
353
+ // but the one thing on it that says *whose* allowance this is has a brand colour of its own,
354
+ // and Anthropic's orange on the `✳` is how you know which of two accounts you are looking at
355
+ // without reading a word. The theme owns the colour: `Brand.Anthropic` is a group, never a
356
+ // value, so a palette that wants a different orange changes it in one place.
357
+ const mark = brand === ""
358
+ ? undefined
359
+ : [{ from: 0, to: byteLength(brand.trimEnd()), hl: cred?.brand?.hl ?? "Sidebar.Heading" }];
360
+ for (const [i, w] of windows.entries()) {
361
+ rows.push({
362
+ text: brand + windowRow(w, labels[i] ?? w.label, labelWidth, bar, glyphs.ascii, marker > 0),
363
+ hl: severityGroup(w),
364
+ spans: mark,
365
+ right: { text: countdown(w, now), hl: "Sidebar.Dim" },
366
+ // Every row opens the panel, so there is no row here you can land on and press `↵` on to
367
+ // no effect — which is the thing that teaches people the column is not interactive.
368
+ command: `${NS}.panel`,
369
+ });
370
+ }
371
+ // The sentence under the bars, and the credit line: both are explanations, and an explanation
372
+ // is the first thing to give up when the column is being asked to say less.
373
+ if (style === "full") {
374
+ const said = plainly(q, now, room);
375
+ if (said) rows.push({ ...said, command: `${NS}.panel` });
376
+ }
377
+ if (style !== "one") {
378
+ const credit = creditRow(q);
379
+ if (credit) rows.push({ ...credit, command: `${NS}.panel` });
380
+ }
381
+ }
382
+ if (rows.length === 0) return null;
383
+ // The way in, on the heading. Every row here opens the panel too, but a key you can press from
384
+ // the composer without going to the sidebar first is a different affordance from a row you have
385
+ // to arrive at — and it is the one somebody who has never focused this column will find.
386
+ return { title: "PLAN", hint: "^L", at: "below" as const, rows };
387
+ }
388
+
389
+ /**
390
+ * Whose allowance this is: the provider's mark, its name, and what the plan is called.
391
+ *
392
+ * The mark and the colour come from the same [`Brand`] the model picker draws, at whatever fidelity
393
+ * the terminal has — a Nerd Font glyph, geometry, or a letter under `ui.ascii_only`. Drawing our
394
+ * own would mean two pictures of Anthropic in one program that disagree.
395
+ *
396
+ * The name is the *instance's* display name rather than the vendor's, because that is what the
397
+ * picker calls it and what a second account of the same vendor would be distinguished by. Falling
398
+ * back to the instance id is deliberate: `claude-cli` is worse than `Claude` and far better than a
399
+ * blank row, and it happens only before the credential list has arrived.
400
+ */
401
+ function accountRow(
402
+ q: QuotaSnapshot,
403
+ who: CredentialInfo[],
404
+ glyphs: { ascii: boolean; nerd: boolean },
405
+ room: number,
406
+ ): StripRow {
407
+ const cred = who.find((c) => c.instance === q.instance);
408
+ const name = cred?.display_name || q.instance;
409
+ const mark = markFor(cred?.brand, glyphs);
410
+ return {
411
+ text: `${mark} ${clip(name, Math.max(4, room - 2))}`,
412
+ // The brand colour on the whole row, not just the mark: a contributed row carries one highlight
413
+ // group, and of the two ways to spend it, the one that makes the account legible at a glance
414
+ // beats a coloured glyph beside grey text.
415
+ hl: cred?.brand?.hl ?? "Sidebar.Heading",
416
+ right: q.plan ? { text: q.plan, hl: "Sidebar.Dim" } : undefined,
417
+ };
418
+ }
419
+
420
+ /** The mark for a provider, at whatever fidelity this terminal has. See the model picker's copy. */
421
+ function markFor(b: Brand | null | undefined, glyphs: { ascii: boolean; nerd: boolean }): string {
422
+ if (!b) return glyphs.ascii ? "?" : "·";
423
+ if (glyphs.ascii) return b.ascii;
424
+ return (glyphs.nerd && b.nerd) || b.mark;
425
+ }
426
+
427
+ /**
428
+ * How many cells a bar in a narrow column is.
429
+ *
430
+ * Six to ten, because the useful resolution here is about an eighth: plenty of room, getting full,
431
+ * nearly out. The exact figure is the number beside it, and a thirty-cell bar in a column this
432
+ * narrow would leave no room for the label that says which allowance it is.
433
+ */
434
+ const BAR_MIN = 5;
435
+ /** `▸ `, or the two spaces that keep an unmarked row in the same column as a marked one. */
436
+ const MARKER = 2;
437
+ /** ` 100%` — never given up, at any width. A bar with no number on it is a picture. */
438
+ const PCT = 5;
439
+
440
+ /**
441
+ * A limit's name, shortened only as far as it has to be, and never past the part that identifies it.
442
+ *
443
+ * The old row put the bar first and clipped whatever was left of the label, which turned
444
+ * `Weekly · Opus 5` into `Weekly ·…` — every character that distinguishes the per-model cap from
445
+ * the plain weekly one, gone, leaving two rows with the same name and different numbers. So the
446
+ * ladder goes the other way: give up the separator, then abbreviate the *base*, and only then clip
447
+ * — and clip the scope, because `Wk Opus…` still says which two things it is about and
448
+ * `Weekly ·…` says neither.
449
+ */
450
+ function compactLabel(w: QuotaWindow, room: number): string {
451
+ // The space between the label and the bar is part of the row too. Forgetting it is one column of
452
+ // overflow at exactly the width where the label was already the thing being squeezed.
453
+ const most = Math.max(6, room - MARKER - BAR_MIN - PCT - 1);
454
+ if (cells(w.label) <= most) return w.label;
455
+ const scope = w.scope ?? "";
456
+ // Not a scoped limit, just a long name from a provider nobody here has heard of.
457
+ if (scope === "") return clip(w.label, most);
458
+ const base = w.label.slice(0, w.label.length - scope.length).replace(/[\s·]+$/, "");
459
+ for (const b of [base, base.replace(/^Weekly$/i, "Wk")]) {
460
+ if (cells(`${b} ${scope}`) <= most) return `${b} ${scope}`;
461
+ }
462
+ const short = base.replace(/^Weekly$/i, "Wk");
463
+ return clip(`${short} ${scope}`, most);
464
+ }
465
+
466
+ function windowRow(
467
+ w: QuotaWindow,
468
+ label: string,
469
+ labelWidth: number,
470
+ bar: number,
471
+ ascii: boolean,
472
+ marked = true,
473
+ ): string {
474
+ // The limit that would actually refuse the next request, marked the way the panel marks it. Two
475
+ // spaces otherwise, so the labels stay in one column — and nothing at all when there is only one
476
+ // limit on the strip, since a marker that is on every row it can be on marks nothing and costs
477
+ // two columns of a label that is being clipped.
478
+ const marker = !marked ? "" : w.active ? (ascii ? "> " : "▸ ") : " ";
479
+ const pct = `${Math.round(w.used_percent)}%`.padStart(PCT);
480
+ const name = clip(label, labelWidth);
481
+ return `${marker}${name}${" ".repeat(Math.max(0, labelWidth - cells(name)))} ${
482
+ meter(w.used_percent / 100, bar, { ascii })
483
+ }${pct}`;
484
+ }
485
+
486
+ /**
487
+ * How long a string is, counted the way {@link clip} counts it.
488
+ *
489
+ * Code points rather than UTF-16 units, so a label and the padding computed for it agree. Not
490
+ * display width — that question is `neosh-tui`'s and is not answerable here — but the labels this
491
+ * pads are limit names, and the alternative is a `.length` that disagrees with the clip on the
492
+ * same string.
493
+ */
494
+ function cells(s: string): number {
495
+ return [...s].length;
496
+ }
497
+
498
+ /**
499
+ * The one row here that is a sentence.
500
+ *
501
+ * Everything above it is a measurement, and a measurement has a direction you have to already know:
502
+ * a bar that fills as an allowance is spent reads as "how much is left" to about half of everyone,
503
+ * and a bare `3h` beside it could as easily be how long it has been running. This says which way
504
+ * round both go, about the window that is actually doing the limiting — which is the only one of
505
+ * the three that answers "may I start something long".
506
+ *
507
+ * It never names the window. The `▸` above already did, and a strip this narrow cannot afford to
508
+ * say `Weekly · Opus 5` twice.
509
+ */
510
+ function plainly(q: QuotaSnapshot, now: number, room: number): StripRow | null {
511
+ const w = q.windows.find((x) => x.active) ?? binding(q.windows);
512
+ if (!w) return null;
513
+ const back = typeof w.resets_at === "number" ? countdown(w, now) : "";
514
+ const left = Math.max(0, 100 - Math.round(w.used_percent));
515
+ const text = left === 0
516
+ ? back
517
+ ? `used up · back in ${back}`
518
+ : "used up"
519
+ : back
520
+ ? `${left}% left · back in ${back}`
521
+ : `${left}% left`;
522
+ return {
523
+ text: clip(text, room),
524
+ // Dim rather than graded: this is the explanation of the rows above, and an explanation that
525
+ // shouts is one more red thing to work out the meaning of. The row it explains carries the
526
+ // grade.
527
+ hl: w.severity === "exhausted" ? "Diagnostic.Error" : "Sidebar.Dim",
528
+ };
529
+ }
530
+
531
+ /** The fullest window, for a provider that did not say which of its limits binds. */
532
+ function binding(windows: QuotaWindow[]): QuotaWindow | undefined {
533
+ return windows.reduce<QuotaWindow | undefined>(
534
+ (best, w) => (!best || w.used_percent > best.used_percent ? w : best),
535
+ undefined,
536
+ );
537
+ }
538
+
539
+ /**
540
+ * How many cells the context meter in the footer is.
541
+ *
542
+ * Eight, because the useful resolution there is about an eighth: plenty of room, getting full,
543
+ * nearly out. The exact figure is the number beside it. The plan strip sizes its own bars from the
544
+ * column it was given (see [`BAR_MIN`]) rather than sharing this, because the footer's width is the
545
+ * terminal's and the sidebar's is a setting.
546
+ */
547
+ const CELLS = 8;
548
+
549
+ /**
550
+ * The group a row wears.
551
+ *
552
+ * Graded by the vendor, not by a threshold of ours — 94% of a weekly window is `critical` and 55%
553
+ * of a session window is `normal`, and a client that decided at 80% for both would be shouting
554
+ * about the wrong one. The binding window is drawn at full weight even when it is not yet worrying,
555
+ * because "this is the one that will stop you" is worth saying before it does.
556
+ */
557
+ function severityGroup(w: QuotaWindow): string {
558
+ switch (w.severity) {
559
+ case "exhausted":
560
+ return "Diagnostic.Error";
561
+ case "critical":
562
+ return "Meter.Full";
563
+ case "warn":
564
+ return "Meter.Warn";
565
+ default:
566
+ return w.active ? "Sidebar.Heading" : "Sidebar.Dim";
567
+ }
568
+ }
569
+
570
+ /** `2h`, `41m`, `now`. Empty for a window that does not roll over. */
571
+ function countdown(w: QuotaWindow, now: number): string {
572
+ if (typeof w.resets_at !== "number") return "";
573
+ const left = w.resets_at - now;
574
+ if (left <= 0) return "now";
575
+ if (left < 3600) return `${Math.max(1, Math.round(left / 60))}m`;
576
+ if (left < 86_400) return `${Math.round(left / 3600)}h`;
577
+ return `${Math.round(left / 86_400)}d`;
578
+ }
579
+
580
+ /**
581
+ * Extra usage, when there is any to say.
582
+ *
583
+ * The interesting part is whether it is *on*: it is the difference between running out meaning
584
+ * "this stops" and running out meaning "this starts costing money", and a strip that did not say
585
+ * which would be describing two very different situations identically.
586
+ */
587
+ function creditRow(q: QuotaSnapshot) {
588
+ const c = q.credits;
589
+ if (!c) return null;
590
+ if (!c.enabled) {
591
+ // Only worth a row once a limit is close. Said all the time it is noise; said at 95% it is the
592
+ // answer to the question the row above just raised.
593
+ const tight = q.windows.some((w) => w.used_percent >= 90);
594
+ if (!tight) return null;
595
+ return { text: "extra usage off", hl: "Sidebar.Dim" as const, right: undefined };
596
+ }
597
+ const balance = typeof c.balance === "string" ? c.balance : "";
598
+ return {
599
+ text: `extra usage${balance ? ` ${balance}` : ""}`,
600
+ hl: "Sidebar.Dim" as const,
601
+ right: typeof c.used_percent === "number"
602
+ ? { text: `${Math.round(c.used_percent)}%`, hl: "Sidebar.Dim" }
603
+ : undefined,
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Say something the first time an allowance crosses the line.
609
+ *
610
+ * Once per window per crossing, which is what `warned` is for: these arrive on every turn boundary
611
+ * and on every poll, and a notification per report is a workspace you learn to ignore. The set
612
+ * clears for a window that has dropped back under, so the *next* time it climbs is news again.
613
+ */
614
+ async function announce(neosh: Neosh, s: QuotaSnapshot, warned: Set<string>) {
615
+ const at = (await neosh.opt.get<number>("usage.warn_at").catch(() => 90)) ?? 90;
616
+ if (at <= 0) return;
617
+
618
+ // Bookkeeping for every window, because a window that has dropped back under has to become news
619
+ // again — but at most one thing *said*, however many crossed at once. An account whose session
620
+ // and weekly limits are both tight is one situation, and two lines about it at the same instant
621
+ // read as two problems.
622
+ let worst: QuotaWindow | null = null;
623
+ for (const w of s.windows) {
624
+ const key = `${s.instance}/${w.id}`;
625
+ if (w.used_percent < at) {
626
+ warned.delete(key);
627
+ continue;
628
+ }
629
+ if (warned.has(key)) continue;
630
+ warned.add(key);
631
+ if (!worst || w.used_percent > worst.used_percent) worst = w;
632
+ }
633
+ if (!worst) return;
634
+
635
+ const left = Math.max(0, 100 - Math.round(worst.used_percent));
636
+ const when = typeof worst.resets_at === "number"
637
+ ? `, back in ${countdown(worst, Date.now() / 1000)}`
638
+ : "";
639
+ // The others are counted rather than named. "and 1 other limit" is the difference between
640
+ // knowing this is the worst of several and thinking it is the only one.
641
+ const others = s.windows.filter((w) => w !== worst && w.used_percent >= at).length;
642
+ const also = others > 0 ? ` (and ${others} other limit${others > 1 ? "s" : ""})` : "";
643
+ neosh.notify(
644
+ left === 0
645
+ ? `${worst.label} is used up${when}${also}`
646
+ : `${left}% of your ${worst.label.toLowerCase()} allowance left${when}${also}`,
647
+ worst.severity === "exhausted" ? "error" : "warn",
648
+ );
649
+ }
650
+
651
+ /* -------------------------------------------------------------------------- */
652
+ /* The panel */
653
+ /* -------------------------------------------------------------------------- */
654
+
655
+ /** What the panel is showing, which every key changes some part of. */
656
+ interface View {
657
+ /** How many days back. `1` switches the resolution to hours, because a day of daily buckets is
658
+ * one column. */
659
+ days: number;
660
+ /** Tokens or their money-equivalent. Two different questions about the same buckets: which model
661
+ * did the *work*, and which model cost the money — and on a lineup with a 30x price spread those
662
+ * are different answers. */
663
+ metric: "tokens" | "cost";
664
+ history: UsageHistory | null;
665
+ quotas: QuotaSnapshot[];
666
+ /** Percentages over time, for the gauges' sparklines. */
667
+ samples: Array<{ at: number; instance: string; window: string; used_percent: number }>;
668
+ loading: boolean;
669
+ /** Whether this panel has asked the provider since it opened. Distinguishes "nobody has said"
670
+ * from "we asked and got nowhere", which are different situations with different answers. */
671
+ asked: boolean;
672
+ /** What the scan could not read, drawn as a caveat rather than swallowed. */
673
+ note: string | null;
674
+ /**
675
+ * Who each instance is, so a gauge can be headed by a provider rather than by an id.
676
+ *
677
+ * `claude-cli` is a configuration key. It was what this panel drew, and on a machine with two
678
+ * accounts it made the one thing the reader needs to tell them apart — which vendor, which plan —
679
+ * the one thing neither block said.
680
+ */
681
+ who: CredentialInfo[];
682
+ }
683
+
684
+ const WINDOWS = [1, 7, 30, 90];
685
+
686
+ async function installPanel({ neosh, subscriptions }: PluginContext) {
687
+ let buf: number | null = null;
688
+ let win: number | null = null;
689
+ /**
690
+ * Which terminal the panel is on.
691
+ *
692
+ * One panel, moved rather than duplicated. A plan meter is a thing you glance at and dismiss, and
693
+ * a second copy of it in another window is one more thing to close — so `^L` in a terminal that
694
+ * is not showing it takes it there. What it must not do is what a single window handle did
695
+ * before views existed: push focus onto somebody else's screen and leave this one blank.
696
+ */
697
+ let on: ViewId | null = null;
698
+ let ns: number | null = null;
699
+ let list: CursoredList<null> | null = null;
700
+ const view: View = {
701
+ days: 30,
702
+ metric: "cost",
703
+ history: null,
704
+ quotas: [],
705
+ samples: [],
706
+ loading: false,
707
+ asked: false,
708
+ note: null,
709
+ who: [],
710
+ };
711
+
712
+ const load = async () => {
713
+ view.loading = true;
714
+ await draw();
715
+ const until = Math.floor(Date.now() / 1000);
716
+ const since = until - view.days * 86_400;
717
+ const resolution: UsageResolution = view.days <= 1 ? "hour" : "day";
718
+ const [history, quotas, samples, who] = await Promise.all([
719
+ neosh.quota.usage({ since, until, resolution }).catch(() => null),
720
+ neosh.quota.list().catch(() => [] as QuotaSnapshot[]),
721
+ // The sparklines want the reset period, not the chart's span: a weekly window drawn over
722
+ // ninety days is a flat line with four cliffs in it, which says nothing about this week.
723
+ neosh.quota.history({ since: until - 7 * 86_400, until }).catch(() => []),
724
+ neosh.agent.credentials().catch(() => [] as CredentialInfo[]),
725
+ ]);
726
+ view.history = history;
727
+ view.who = who;
728
+ // Merged, never assigned. This read started seconds ago — the transcript scan is the slow part
729
+ // — and a snapshot that arrived by event in the meantime is newer than anything in it. Assigned,
730
+ // a poll landing during the scan was drawn for an instant and then replaced by the empty list
731
+ // this call had read before it landed, and the panel said nothing had reported an allowance
732
+ // while the sidebar three columns away was showing three.
733
+ view.quotas = freshest(view.quotas, quotas);
734
+ view.samples = samples;
735
+ view.note = history ? scanNote(history) : "could not read the transcripts";
736
+ view.loading = false;
737
+ await draw();
738
+ };
739
+
740
+ const draw = async () => {
741
+ if (buf === null || ns === null || list === null) return;
742
+ const cols = Math.max(40, await panelWidth(neosh, win));
743
+ const [ascii, nerd] = await Promise.all([
744
+ neosh.opt.get<boolean>("ui.ascii_only").catch(() => false),
745
+ neosh.opt.get<boolean>("ui.nerd_font").catch(() => false),
746
+ ]);
747
+ const glyphs = { ascii: ascii ?? false, nerd: nerd ?? false };
748
+ const rows = await body(neosh, view, cols, glyphs);
749
+ list.setRows(rows);
750
+ await list.render({ win: win ?? undefined });
751
+ };
752
+
753
+ const open = async (where?: ViewId) => {
754
+ if (win !== null && (where === undefined || on === where)) {
755
+ await neosh.focus.push(win);
756
+ return;
757
+ }
758
+ // Asked for from a terminal that is not the one it is on. Taken there rather than opened twice.
759
+ if (win !== null) await close();
760
+ on = where ?? null;
761
+ // The span it opens on is a setting, snapped to one the keys can also reach — otherwise `]`
762
+ // from a configured 45 days lands on whichever of the four it decides is next, and the number
763
+ // you set is one you can never get back to.
764
+ const configured = (await neosh.opt.get<number>("usage.days").catch(() => 30)) ?? 30;
765
+ view.days = WINDOWS.reduce((best, d) =>
766
+ Math.abs(d - configured) < Math.abs(best - configured) ? d : best
767
+ , WINDOWS[0]!);
768
+ buf ??= await neosh.buf.create({ name: "[usage]", scratch: true, kind: KIND });
769
+ ns ??= await neosh.ns.create(`${NS}.panel`);
770
+ // A float rather than the main dock. Taking the dock would put the transcript away to show a
771
+ // chart, and this is a thing you glance at between turns — not a place you go and come back
772
+ // from. Wide, because a chart narrower than its span is a chart with columns missing.
773
+ // Named rather than derived: the buffer is reused between openings, so "the terminal showing
774
+ // it" is the one it was last on rather than the one asking for it now.
775
+ win = await (on === null ? neosh : neosh.view.at(on)).float.open(buf, {
776
+ anchor: { kind: "screen" },
777
+ width: { kind: "max", n: 104 },
778
+ height: { kind: "max", n: 34 },
779
+ border: "rounded",
780
+ title: " usage ",
781
+ focusable: true,
782
+ });
783
+ list = new CursoredList<null>(neosh, buf, ns);
784
+ await neosh.focus.push(win);
785
+ await draw();
786
+ // Ask while the scan runs. Opening this is as good a statement of "I want to know now" as
787
+ // pressing `r`, and the answer arrives through `onChange` rather than through the load — which
788
+ // is why the two writers below have to agree about which of them is fresher.
789
+ void neosh.quota.refresh().catch(() => {});
790
+ view.asked = true;
791
+ await load();
792
+ };
793
+
794
+ const close = async () => {
795
+ if (win === null) return;
796
+ const w = win;
797
+ win = null;
798
+ on = null;
799
+ list = null;
800
+ await neosh.focus.pop().catch(() => {});
801
+ await neosh.win.close(w).catch(() => {});
802
+ };
803
+
804
+ // Every key is a named command, so `^Z` lists them and `init.ts` can move them. A `switch` on the
805
+ // key inside a handler is the thing this replaced.
806
+ const cmd = async (name: string, desc: string, fn: () => void | Promise<void>) => {
807
+ subscriptions.push(await neosh.cmd.register(`${NS}.${name}`, fn, { desc }));
808
+ };
809
+
810
+ subscriptions.push(
811
+ await neosh.cmd.register(`${NS}.panel`, (_args, key) => open(key?.view), {
812
+ desc: "What the plan has left, and where the week went",
813
+ }),
814
+ );
815
+ await cmd("panel.close", "Close the usage panel", close);
816
+ await cmd("panel.refresh", "Ask the provider again", async () => {
817
+ await neosh.quota.refresh().catch(() => {});
818
+ view.asked = true;
819
+ await load();
820
+ });
821
+ await cmd("panel.tokens", "Show tokens rather than cost", async () => {
822
+ view.metric = "tokens";
823
+ await draw();
824
+ });
825
+ await cmd("panel.cost", "Show cost rather than tokens", async () => {
826
+ view.metric = "cost";
827
+ await draw();
828
+ });
829
+ await cmd("panel.metric", "Swap between tokens and cost", async () => {
830
+ view.metric = view.metric === "cost" ? "tokens" : "cost";
831
+ await draw();
832
+ });
833
+ await cmd("panel.wider", "A longer span", async () => {
834
+ const i = WINDOWS.indexOf(view.days);
835
+ view.days = WINDOWS[Math.min(WINDOWS.length - 1, i + 1)] ?? view.days;
836
+ await load();
837
+ });
838
+ await cmd("panel.narrower", "A shorter span", async () => {
839
+ const i = WINDOWS.indexOf(view.days);
840
+ view.days = WINDOWS[Math.max(0, i - 1)] ?? view.days;
841
+ await load();
842
+ });
843
+ for (const days of WINDOWS) {
844
+ await cmd(`panel.span.${days}`, `${days === 1 ? "The last day" : `${days} days`}`, async () => {
845
+ view.days = days;
846
+ await load();
847
+ });
848
+ }
849
+ await cmd("panel.down", "Move down", async () => {
850
+ list?.move(1);
851
+ await list?.render({ win: win ?? undefined });
852
+ });
853
+ await cmd("panel.up", "Move up", async () => {
854
+ list?.move(-1);
855
+ await list?.render({ win: win ?? undefined });
856
+ });
857
+ await cmd("report", "What this conversation has used", () => report(neosh));
858
+
859
+ const scope = { kind: "buf_kind", name: KIND } as const;
860
+ const key = async (lhs: string, name: string, desc?: string) => {
861
+ await neosh.keymap.set("chat", lhs, `${NS}.${name}`, { scope, desc });
862
+ };
863
+ // At chat scope, because this is the way *in*. Binding it inside the panel as well would be a
864
+ // key that reopens what is already open.
865
+ await neosh.keymap.set("chat", "<C-l>", `${NS}.panel`, { desc: "Plan usage and history" });
866
+ await key("q", "panel.close");
867
+ await key("<Esc>", "panel.close");
868
+ await key("<C-c>", "panel.close");
869
+ await key("r", "panel.refresh", "Ask the provider again");
870
+ await key("t", "panel.tokens", "Tokens");
871
+ await key("c", "panel.cost", "Cost");
872
+ await key("<Tab>", "panel.metric", "Tokens or cost");
873
+ await key("j", "panel.down");
874
+ await key("k", "panel.up");
875
+ await key("<Down>", "panel.down");
876
+ await key("<Up>", "panel.up");
877
+ await key("]", "panel.wider", "A longer span");
878
+ await key("[", "panel.narrower", "A shorter span");
879
+ for (const [lhs, days] of [["1", 1], ["7", 7], ["3", 30], ["9", 90]] as const) {
880
+ await key(lhs, `panel.span.${days}`);
881
+ }
882
+
883
+ // A number that moved while the panel is open is a panel that is wrong. Only the gauges are
884
+ // refreshed on this: the history comes from files and re-scanning them on every turn boundary
885
+ // would be a panel that stutters.
886
+ subscriptions.push(neosh.quota.onChange(async (s) => {
887
+ if (win === null) return;
888
+ view.quotas = freshest(view.quotas, [s]);
889
+ await draw();
890
+ }));
891
+ subscriptions.push({ dispose: () => void close() });
892
+ }
893
+
894
+ /**
895
+ * Two sets of snapshots, keeping whichever account is the later observation.
896
+ *
897
+ * Per instance rather than per set: a slow read may be newer for one account and older for another,
898
+ * and taking the newer *set* would throw away the good half of it.
899
+ */
900
+ function freshest(a: QuotaSnapshot[], b: QuotaSnapshot[]): QuotaSnapshot[] {
901
+ const by = new Map<string, QuotaSnapshot>();
902
+ for (const s of [...a, ...b]) {
903
+ const at = by.get(s.instance);
904
+ if (!at || s.observed_at >= at.observed_at) by.set(s.instance, s);
905
+ }
906
+ // Sorted, so the order does not depend on which of the two writers happened to run first — a
907
+ // panel whose gauges swap places when a poll lands is one you cannot read while it updates.
908
+ return [...by.values()].sort((x, y) => x.instance.localeCompare(y.instance));
909
+ }
910
+
911
+ /**
912
+ * How wide the panel is.
913
+ *
914
+ * Asked of the window rather than assumed, because this is a docked main window and the terminal is
915
+ * whatever size the terminal is. A chart built to a guessed width wraps, and a wrapped chart is not
916
+ * a chart — it is two rows of glyphs that no longer line up with their axis.
917
+ */
918
+ async function panelWidth(neosh: Neosh, win: number | null): Promise<number> {
919
+ if (win === null) return 80;
920
+ const vp = await neosh.win.viewport(win).catch(() => null);
921
+ return vp?.width ?? 80;
922
+ }
923
+
924
+ /* -------------------------------------------------------------------------- */
925
+ /* Drawing it */
926
+ /* -------------------------------------------------------------------------- */
927
+
928
+ /**
929
+ * The whole panel, top to bottom.
930
+ *
931
+ * Gauges first, because they are the thing that can stop you and the reason you pressed the key.
932
+ * The chart second, because "where did it go" is a question you only ask once you have seen the
933
+ * answer to "how much is left".
934
+ */
935
+ async function body(
936
+ neosh: Neosh,
937
+ view: View,
938
+ cols: number,
939
+ glyphs: { ascii: boolean; nerd: boolean },
940
+ ): Promise<ListRow<null>[]> {
941
+ const { ascii } = glyphs;
942
+ const rows: ListRow<null>[] = [];
943
+ const rule = () => rows.push({ text: "─".repeat(cols), hl: "Separator", inert: true });
944
+ const blank = () => rows.push({ text: "", inert: true });
945
+ const head = (text: string, right?: string) =>
946
+ rows.push({
947
+ text: ` ${text}`,
948
+ hl: "Sidebar.Heading",
949
+ right: right ? { text: `${right} `, hl: "Comment" } : undefined,
950
+ inert: true,
951
+ });
952
+
953
+ const now = Date.now() / 1000;
954
+ // `r refresh`, not `usage.panel.refresh · r`. A command name in a heading is the program telling
955
+ // you what it calls something rather than what you can do with it.
956
+ head("YOUR PLAN", view.quotas.length ? "r refresh" : undefined);
957
+ rule();
958
+ if (view.quotas.length > 0) {
959
+ // What these numbers *are*, which no amount of care with the bars can say on its own. A rolling
960
+ // allowance is not a balance and not a token count: it refills on a clock, the vendor decides
961
+ // how big it is and will not itemise it, and a turn you ran in the vendor's own CLI spent it
962
+ // too. Everything below is a percentage of something opaque, and saying so once is what stops
963
+ // people planning around it as though it were money.
964
+ rows.push({
965
+ text: " Rolling allowances your plan enforces. They refill on a clock, and the vendor",
966
+ hl: "Comment",
967
+ inert: true,
968
+ });
969
+ rows.push({
970
+ text: " never says how big they are — so these are percentages, never token counts.",
971
+ hl: "Comment",
972
+ inert: true,
973
+ });
974
+ blank();
975
+ }
976
+ if (view.quotas.length === 0) {
977
+ // Two different situations, and only one of them is about to change on its own. A workspace
978
+ // that has never been told is waiting for a turn; one that asked and got nowhere is waiting
979
+ // for a network, a login, or an endpoint that has told it to ask less often — and saying
980
+ // "nothing has reported yet" to the second is an invitation to press `r` forever.
981
+ rows.push({
982
+ text: view.asked
983
+ ? " asked, and could not get an answer"
984
+ : " nothing has reported an allowance yet",
985
+ hl: "Comment",
986
+ inert: true,
987
+ });
988
+ rows.push({
989
+ text: view.asked
990
+ ? " a turn will report it; r asks again"
991
+ : " run a turn, or press r to ask now",
992
+ hl: "Comment",
993
+ inert: true,
994
+ });
995
+ }
996
+ for (const q of view.quotas) {
997
+ rows.push(...gaugeBlock(q, view.who, view.samples, cols, glyphs, now));
998
+ }
999
+
1000
+ blank();
1001
+ const label = view.days === 1 ? "THE LAST DAY, BY HOUR" : `THE LAST ${view.days} DAYS`;
1002
+ head(label, `${view.metric} · ⇥`);
1003
+ rule();
1004
+ if (view.loading) {
1005
+ rows.push({ text: " reading the transcripts…", hl: "Comment", inert: true });
1006
+ } else if (!view.history || view.history.buckets.length === 0) {
1007
+ rows.push({ text: " nothing in this span", hl: "Comment", inert: true });
1008
+ } else {
1009
+ rows.push(...chart(view.history, view.metric, cols, ascii));
1010
+ blank();
1011
+ rows.push(...breakdown(view.history, view.metric, cols));
1012
+ }
1013
+ if (view.note) {
1014
+ blank();
1015
+ rows.push({ text: ` ${view.note}`, hl: "Diagnostic.Warn", inert: true });
1016
+ }
1017
+
1018
+ // Anything a plugin contributed, last, under its own rule. Rows rather than a callback, so a
1019
+ // contributor that is not loaded is simply absent rather than an error at draw time.
1020
+ const extra = await neosh.ext
1021
+ .list<{ text: string; hl?: string; right?: string; command?: string }>(POINT_ROW)
1022
+ .catch(() => []);
1023
+ if (extra.length > 0) {
1024
+ blank();
1025
+ head("ALSO");
1026
+ rule();
1027
+ for (const c of extra) {
1028
+ if (typeof c.item?.text !== "string") continue;
1029
+ rows.push({
1030
+ text: ` ${c.item.text}`,
1031
+ hl: c.item.hl,
1032
+ right: c.item.right ? { text: `${c.item.right} `, hl: "Comment" } : undefined,
1033
+ inert: typeof c.item.command !== "string",
1034
+ });
1035
+ }
1036
+ }
1037
+
1038
+ blank();
1039
+ rows.push({ text: ` ${hints()}`, hl: "Comment", inert: true });
1040
+ return rows;
1041
+ }
1042
+
1043
+ function hints(): string {
1044
+ return "1 7 3 9 span [ ] wider narrower ⇥ tokens/cost r refresh q close";
1045
+ }
1046
+
1047
+ /**
1048
+ * One account: its plan, its windows, and how each has moved.
1049
+ *
1050
+ * The sparkline is the part the strip cannot show. A window at 94% is one fact; a window that was
1051
+ * at 40% two hours ago is a different situation from one that has been at 92% all week, and only
1052
+ * the second of those is a reason to stop.
1053
+ */
1054
+ function gaugeBlock(
1055
+ q: QuotaSnapshot,
1056
+ who: CredentialInfo[],
1057
+ samples: View["samples"],
1058
+ cols: number,
1059
+ glyphs: { ascii: boolean; nerd: boolean },
1060
+ now: number,
1061
+ ): ListRow<null>[] {
1062
+ const { ascii } = glyphs;
1063
+ const rows: ListRow<null>[] = [];
1064
+ const age = now - q.observed_at;
1065
+ // The provider, drawn the way the model picker draws it: same mark, same brand colour, same name.
1066
+ // This row used to be the instance id, which is a configuration key — and on a machine with a
1067
+ // Claude plan and a Codex one it made the single thing that tells the two blocks apart the one
1068
+ // thing neither of them said.
1069
+ const cred = who.find((c) => c.instance === q.instance);
1070
+ rows.push({
1071
+ text: ` ${markFor(cred?.brand, glyphs)} ${cred?.display_name || q.instance}${
1072
+ q.plan ? ` ${q.plan}` : ""
1073
+ }`,
1074
+ hl: cred?.brand?.hl ?? "Sidebar.Heading",
1075
+ // How old the number is, always. A percentage with no age on it is one people trust for longer
1076
+ // than they should — and at rest, with polling off, "an hour ago" is the whole answer.
1077
+ right: { text: `${freshness(age, q.source)} `, hl: "Comment" },
1078
+ inert: true,
1079
+ });
1080
+
1081
+ const SPARK = 16;
1082
+ const labelWidth = Math.max(...q.windows.map((w) => byteLength(w.label)), 8);
1083
+ // Everything on the row that is not the bar: the indent and marker, the label, the percentage,
1084
+ // the sparkline and the longest countdown. Subtracted rather than estimated, because the bar is
1085
+ // the only elastic thing here and anything the arithmetic forgets is drawn off the right edge of
1086
+ // the float — where it is not clipped so much as simply gone, which is how `resets in 8h` became
1087
+ // the word `resets`.
1088
+ const FIXED = 5 + labelWidth + 2 + 6 + 2 + SPARK + 2 + 18;
1089
+ const barWidth = Math.max(8, Math.min(40, cols - FIXED));
1090
+
1091
+ // What each column is, once, over the columns themselves.
1092
+ //
1093
+ // Every part of the row below is a measurement with a direction you have to already know: a bar
1094
+ // that fills as an allowance is *spent*, a sparkline that is not labelled could be any span, and
1095
+ // a bare `3h` is as easily "running for" as "back in". Naming them costs one dim row per account
1096
+ // and is the difference between a display you read and one you interpret.
1097
+ rows.push({
1098
+ text: ` ${"limit".padEnd(labelWidth)} ${"used".padEnd(barWidth)} ${
1099
+ "last 7 days".padEnd(SPARK)
1100
+ } refills`,
1101
+ hl: "Comment",
1102
+ inert: true,
1103
+ });
1104
+
1105
+ for (const w of q.windows) {
1106
+ const pct = Math.round(w.used_percent);
1107
+ const bar = meter(w.used_percent / 100, barWidth, { ascii });
1108
+ const spark = sparkline(
1109
+ samples.filter((s) => s.instance === q.instance && s.window === w.id).map((s) => s.used_percent),
1110
+ SPARK,
1111
+ ascii,
1112
+ );
1113
+ const marker = w.active ? (ascii ? ">" : "▸") : " ";
1114
+ const head = ` ${marker} ${w.label.padEnd(labelWidth)} `;
1115
+ // Everything inline, and no right-aligned column. A `right` here is drawn as virtual text after
1116
+ // the row's own text, so a row this wide pushes it past the edge — and the one thing on it that
1117
+ // is worth having when it is nearly full is when it comes back.
1118
+ const text = `${head}${bar} ${String(pct).padStart(3)}% ${spark} ${resetsWord(w, now)}`;
1119
+ // The bar and its percentage carry the severity colour; the label, the spark and the countdown
1120
+ // stay in the text colour, so a block of four rows does not read as four alarms.
1121
+ const barFrom = byteLength(head);
1122
+ rows.push({
1123
+ text,
1124
+ hl: w.active ? undefined : "Comment",
1125
+ spans: [{ from: barFrom, to: barFrom + byteLength(bar) + 5, hl: severityGroup(w) }],
1126
+ inert: true,
1127
+ });
1128
+ }
1129
+ const credit = creditRow(q);
1130
+ if (credit) {
1131
+ rows.push({
1132
+ text: ` ${credit.text}`,
1133
+ hl: "Comment",
1134
+ right: credit.right ? { text: `${credit.right.text} `, hl: "Comment" } : undefined,
1135
+ inert: true,
1136
+ });
1137
+ }
1138
+ rows.push({ text: "", inert: true });
1139
+ return rows;
1140
+ }
1141
+
1142
+ /** `just now`, `4m ago`, `2h ago (last turn)`. */
1143
+ function freshness(age: number, source: QuotaSnapshot["source"]): string {
1144
+ const how = source === "turn" ? " · last turn" : source === "plugin" ? " · plugin" : "";
1145
+ if (age < 90) return `just now${how}`;
1146
+ if (age < 3600) return `${Math.round(age / 60)}m ago${how}`;
1147
+ if (age < 86_400) return `${Math.round(age / 3600)}h ago${how}`;
1148
+ return `${Math.round(age / 86_400)}d ago${how}`;
1149
+ }
1150
+
1151
+ /** `resets in 2h 14m`, `resets at the top of the hour`, or nothing. */
1152
+ function resetsWord(w: QuotaWindow, now: number): string {
1153
+ if (typeof w.resets_at !== "number") return "";
1154
+ const left = w.resets_at - now;
1155
+ if (left <= 0) return "resetting";
1156
+ // Days, once there are any. `elapsed` counts a turn, which never runs for a week, so it tops out
1157
+ // at hours — and a weekly allowance came back as `resets in 164h 34m`, a number you have to do
1158
+ // arithmetic on to find out it means Tuesday.
1159
+ if (left >= 86_400) {
1160
+ const days = Math.floor(left / 86_400);
1161
+ const hours = Math.floor((left % 86_400) / 3600);
1162
+ return `resets in ${days}d${hours > 0 ? ` ${hours}h` : ""}`;
1163
+ }
1164
+ return `resets in ${elapsed(left * 1000).replace(/ \d+s$/, "")}`;
1165
+ }
1166
+
1167
+ /**
1168
+ * A line, in one row.
1169
+ *
1170
+ * Eight block heights, scaled to 0-100 rather than to the data's own range: a window that has been
1171
+ * between 91% and 94% all day is a *flat line near the top*, and auto-scaling it would draw a
1172
+ * dramatic climb out of three percentage points. The one thing this chart is for is the shape of an
1173
+ * allowance being used up, and that shape only means anything against the whole allowance.
1174
+ */
1175
+ function sparkline(values: number[], width: number, ascii: boolean): string {
1176
+ if (values.length === 0) return " ".repeat(width);
1177
+ const glyphs = ascii ? [".", ".", ":", ":", "|", "|", "|", "|"] : ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
1178
+ // The most recent `width` points, because the right-hand end is the part that is still true.
1179
+ const tail = values.slice(-width);
1180
+ const out = tail.map((v) => {
1181
+ const i = Math.min(glyphs.length - 1, Math.max(0, Math.floor((v / 100) * glyphs.length)));
1182
+ return glyphs[i] ?? glyphs[0]!;
1183
+ });
1184
+ // Padded on the *left*, so the newest point is always in the same column whether there are three
1185
+ // samples or thirty. Padded right, the line would slide across the row as history accumulated.
1186
+ return " ".repeat(Math.max(0, width - out.length)) + out.join("");
1187
+ }
1188
+
1189
+ /**
1190
+ * The history, as bars.
1191
+ *
1192
+ * Vertical, not horizontal, and the reason is the axis: the useful comparison is between *periods*
1193
+ * — was yesterday heavier than today — and periods read left to right. A horizontal bar per day is
1194
+ * a list you have to scan down to compare, which is the one thing a chart is supposed to save you.
1195
+ *
1196
+ * Eight rows of half-blocks gives sixteen levels in eight terminal rows, which is enough resolution
1197
+ * to see a spike and cheap enough to redraw on every keystroke.
1198
+ */
1199
+ const CHART_ROWS = 8;
1200
+
1201
+ function chart(
1202
+ history: UsageHistory,
1203
+ metric: View["metric"],
1204
+ cols: number,
1205
+ ascii: boolean,
1206
+ ): ListRow<null>[] {
1207
+ const span = history.resolution === "hour" ? 3_600 : 86_400;
1208
+ // The grid is the *scan's* grid, anchored on a boundary it actually produced.
1209
+ //
1210
+ // A day starts at local midnight, and only the scan knows which zone that was — so a bucket for
1211
+ // the 21st is stamped 22:00 UTC on the 20th two zones east of Greenwich. Re-deriving the grid
1212
+ // here by flooring to a UTC day put every one of those in the previous column, which is a chart
1213
+ // whose last day is yesterday and whose every label is off by one.
1214
+ const anchor = history.buckets[0]?.start ?? alignDown(history.since, span);
1215
+ const gridAt = (t: number) => anchor + Math.floor((t - anchor) / span) * span;
1216
+
1217
+ // Every period in the span, including the empty ones. A chart drawn only from the periods that
1218
+ // had activity is a chart with no gaps in it — which is the same picture as working every single
1219
+ // day, and the opposite of the truth.
1220
+ const periods: number[] = [];
1221
+ for (let t = gridAt(history.since); t < history.until; t += span) periods.push(t);
1222
+
1223
+ const gutter = 9;
1224
+ const plotWidth = Math.max(8, cols - gutter - 2);
1225
+ // How many columns one period gets. A day drawn one cell wide in a plot seventy cells across is
1226
+ // technically a bar chart and reads as a rendering fault — so a short span spends the room it
1227
+ // has, up to a width past which a bar stops looking like a measurement and starts looking like a
1228
+ // block. The last column of a wide bar is left blank as the gap between periods.
1229
+ const cell = Math.max(1, Math.min(7, Math.floor(plotWidth / Math.max(1, periods.length))));
1230
+ const ink = cell > 1 ? cell - 1 : 1;
1231
+ // Whole periods only. Half a bar at the left edge is a period the reader has no way to know is
1232
+ // half there, and it is always the oldest one — the one a partial scan would also have lost.
1233
+ const fit = Math.max(1, Math.floor(plotWidth / cell));
1234
+ const shown = periods.slice(-fit);
1235
+ const dropped = periods.length - shown.length;
1236
+
1237
+ const byPeriod = new Map<number, UsageBucket[]>();
1238
+ for (const b of history.buckets) {
1239
+ // Already on the grid, so this is a lookup key and not a second flooring. Snapped anyway, so a
1240
+ // scan that ever returns an unaligned bucket lands in a column rather than in nothing.
1241
+ const key = gridAt(b.start);
1242
+ const at = byPeriod.get(key);
1243
+ if (at) at.push(b);
1244
+ else byPeriod.set(key, [b]);
1245
+ }
1246
+ const valueOf = (t: number) =>
1247
+ (byPeriod.get(t) ?? []).reduce((sum, b) => sum + amount(b, metric), 0);
1248
+
1249
+ const values = shown.map(valueOf);
1250
+ const peak = Math.max(...values, 0);
1251
+ const rows: ListRow<null>[] = [];
1252
+ if (peak <= 0) {
1253
+ // "Nothing happened" and "nothing here has a price" are different facts, and only one of them
1254
+ // is about you. A span with eight thousand calls in it and no published rate for any of the
1255
+ // models drew a flat "nothing in this span" directly above a table listing every one of them —
1256
+ // which reads as a bug in the chart, and is worse than that: it is the panel disagreeing with
1257
+ // itself about whether the week happened.
1258
+ if (metric === "cost" && history.buckets.length > 0) {
1259
+ return [
1260
+ { text: " no published rate for anything in this span", hl: "Comment", inert: true },
1261
+ { text: " ⇥ for tokens, which are counted either way", hl: "Comment", inert: true },
1262
+ ];
1263
+ }
1264
+ return [{ text: " nothing in this span", hl: "Comment", inert: true }];
1265
+ }
1266
+
1267
+ // Two levels per row: a full block and a half block. The alternative is eight rows of eight
1268
+ // levels, which cannot draw anything smaller than an eighth of the peak — and on a week with one
1269
+ // heavy day, that is every other day drawn as zero.
1270
+ const levels = CHART_ROWS * 2;
1271
+ const heights = values.map((v) => Math.round((v / peak) * levels));
1272
+ const [full, half, empty] = ascii ? ["#", "=", " "] : ["█", "▄", " "];
1273
+
1274
+ for (let row = CHART_ROWS - 1; row >= 0; row--) {
1275
+ const cells = heights.map((h) => {
1276
+ const filled = h - row * 2;
1277
+ const glyph = filled >= 2 ? full! : filled === 1 ? half! : empty!;
1278
+ return glyph.repeat(ink) + " ".repeat(cell - ink);
1279
+ });
1280
+ // The axis labels sit in the gutter: the peak on the top row and zero on the bottom, which is
1281
+ // the least a reader needs to turn a shape back into a number.
1282
+ const label = row === CHART_ROWS - 1
1283
+ ? formatted(peak, metric).padStart(gutter - 1)
1284
+ : row === 0
1285
+ ? formatted(0, metric).padStart(gutter - 1)
1286
+ : "".padStart(gutter - 1);
1287
+ rows.push({
1288
+ text: `${label} ${cells.join("")}`,
1289
+ spans: [{ from: gutter, to: gutter + byteLength(cells.join("")), hl: "Meter.Fill" }],
1290
+ hl: "Comment",
1291
+ inert: true,
1292
+ });
1293
+ }
1294
+ rows.push({
1295
+ text: `${"".padStart(gutter - 1)} ${"─".repeat(shown.length * cell)}`,
1296
+ hl: "Separator",
1297
+ inert: true,
1298
+ });
1299
+ rows.push({
1300
+ text: `${"".padStart(gutter - 1)} ${axis(shown, span, shown.length * cell)}`,
1301
+ hl: "Comment",
1302
+ inert: true,
1303
+ });
1304
+ if (dropped > 0) {
1305
+ // Said, never silent. A chart that quietly dropped the oldest columns reads as a span that
1306
+ // started later than it did.
1307
+ rows.push({
1308
+ text: ` the oldest ${dropped} ${span === 3_600 ? "hours" : "days"} do not fit this window`,
1309
+ hl: "Comment",
1310
+ inert: true,
1311
+ });
1312
+ }
1313
+ return rows;
1314
+ }
1315
+
1316
+ /**
1317
+ * The label strip under the bars.
1318
+ *
1319
+ * Three labels at most — the ends and the middle — and every one of them is dropped rather than
1320
+ * drawn over its neighbour. Labelling every column needs four characters per column and there is
1321
+ * one; labelling none leaves a shape with no idea when it happened; overlapping them produces
1322
+ * `13120/8`, which is worse than either because it looks like a date.
1323
+ *
1324
+ * Right-hand end first, because it is the one that is always worth having: it says how recent the
1325
+ * newest column is, which is the difference between a chart of this week and a chart of last.
1326
+ */
1327
+ function axis(periods: number[], span: number, width: number): string {
1328
+ const out: string[] = new Array(width).fill(" ");
1329
+ if (periods.length === 0) return out.join("");
1330
+
1331
+ const label = (t: number) => {
1332
+ const d = new Date(t * 1000);
1333
+ return span === 3_600
1334
+ ? `${String(d.getHours()).padStart(2, "0")}h`
1335
+ : `${d.getDate()}/${d.getMonth() + 1}`;
1336
+ };
1337
+ /** Write it only if every cell it needs is still blank, plus a space either side. */
1338
+ const put = (at: number, text: string): boolean => {
1339
+ const start = Math.min(Math.max(0, at), width - text.length);
1340
+ if (start < 0) return false;
1341
+ for (let i = start - 1; i <= start + text.length; i++) {
1342
+ if (i >= 0 && i < width && out[i] !== " ") return false;
1343
+ }
1344
+ for (let i = 0; i < text.length; i++) out[start + i] = text[i]!;
1345
+ return true;
1346
+ };
1347
+
1348
+ const last = label(periods[periods.length - 1]!);
1349
+ put(width - last.length, last);
1350
+ if (periods.length > 1) put(0, label(periods[0]!));
1351
+ if (periods.length > 4) {
1352
+ const mid = label(periods[Math.floor(periods.length / 2)]!);
1353
+ put(Math.floor((width - mid.length) / 2), mid);
1354
+ }
1355
+ return out.join("");
1356
+ }
1357
+
1358
+ /**
1359
+ * Which models the span went on, biggest first.
1360
+ *
1361
+ * By model rather than by day, because it is the actionable cut: "Opus was 80% of the cost" is
1362
+ * something you can do something about, and "Tuesday was busy" is not.
1363
+ */
1364
+ function breakdown(history: UsageHistory, metric: View["metric"], cols: number): ListRow<null>[] {
1365
+ const totals = new Map<string, { instance: string; model: string; value: number; usage: UsageBucket["usage"]; requests: number; priced: boolean }>();
1366
+ for (const b of history.buckets) {
1367
+ const key = `${b.instance}/${b.model}`;
1368
+ const at = totals.get(key) ?? {
1369
+ instance: b.instance,
1370
+ model: b.model,
1371
+ value: 0,
1372
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, thinking_tokens: 0 },
1373
+ requests: 0,
1374
+ priced: true,
1375
+ };
1376
+ at.value += amount(b, metric);
1377
+ at.usage.input_tokens += b.usage.input_tokens;
1378
+ at.usage.output_tokens += b.usage.output_tokens;
1379
+ at.usage.cache_read_tokens += b.usage.cache_read_tokens;
1380
+ at.usage.cache_write_tokens += b.usage.cache_write_tokens;
1381
+ at.requests += b.requests;
1382
+ at.priced &&= b.basis === "priced";
1383
+ totals.set(key, at);
1384
+ }
1385
+ const ranked = [...totals.values()].sort((a, b) => b.value - a.value);
1386
+ if (ranked.length === 0) return [];
1387
+
1388
+ const grand = ranked.reduce((s, r) => s + r.value, 0);
1389
+ const rows: ListRow<null>[] = [];
1390
+ const nameWidth = Math.min(34, Math.max(...ranked.map((r) => byteLength(r.model)), 10));
1391
+ for (const r of ranked.slice(0, 12)) {
1392
+ const share = grand > 0 ? r.value / grand : 0;
1393
+ // A share bar rather than a second number: the eye reads twelve bars as a ranking and twelve
1394
+ // percentages as twelve things to compare one at a time.
1395
+ const bar = meter(share, 10, {});
1396
+ const cost = r.priced ? formatted(r.value, metric) : `${formatted(r.value, metric)}*`;
1397
+ rows.push({
1398
+ text: ` ${clip(r.model, nameWidth).padEnd(nameWidth)} ${bar} ${cost.padStart(9)} ${String(r.requests).padStart(5)} calls`,
1399
+ hl: "Comment",
1400
+ right: {
1401
+ text: `${compact(r.usage.cache_read_tokens)} cached `,
1402
+ hl: "Comment",
1403
+ },
1404
+ inert: true,
1405
+ });
1406
+ }
1407
+ if (ranked.length > 12) {
1408
+ rows.push({ text: ` and ${ranked.length - 12} more`, hl: "Comment", inert: true });
1409
+ }
1410
+ // The cache line, because it is the difference between a span that is expensive and one that only
1411
+ // looks it: a week that is 90% cache reads cost a fraction of what its token count implies.
1412
+ const saved = history.buckets.reduce((s, b) => s + b.cache_savings_usd, 0);
1413
+ if (saved > 0 && metric === "cost") {
1414
+ rows.push({ text: "", inert: true });
1415
+ rows.push({
1416
+ text: ` caching saved ${money(saved)} of it`,
1417
+ hl: "Diagnostic.Ok",
1418
+ inert: true,
1419
+ });
1420
+ }
1421
+ if (!history.fully_priced && metric === "cost") {
1422
+ rows.push({
1423
+ text: " * no published rate for this model; its tokens count, its cost does not",
1424
+ hl: "Comment",
1425
+ inert: true,
1426
+ });
1427
+ }
1428
+ void cols;
1429
+ return rows;
1430
+ }
1431
+
1432
+ function amount(b: UsageBucket, metric: View["metric"]): number {
1433
+ if (metric === "cost") return b.cost_usd;
1434
+ const u = b.usage;
1435
+ // Thinking tokens are reported *inside* output and must never be added on top.
1436
+ return u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
1437
+ }
1438
+
1439
+ function formatted(v: number, metric: View["metric"]): string {
1440
+ return metric === "cost" ? money(v) : compact(v);
1441
+ }
1442
+
1443
+ function alignDown(t: number, span: number): number {
1444
+ return Math.floor(t / span) * span;
1445
+ }
1446
+
1447
+ function clip(s: string, n: number): string {
1448
+ const chars = [...s];
1449
+ return chars.length <= n ? s : `${chars.slice(0, Math.max(1, n - 1)).join("")}…`;
1450
+ }
1451
+
1452
+ /** What the scan could not read, in one line, or nothing when it read everything. */
1453
+ function scanNote(history: UsageHistory): string | null {
1454
+ const bad = history.sources.filter((s) => s.status === "partial" || s.status === "failed");
1455
+ if (bad.length === 0) return null;
1456
+ const first = bad[0]!;
1457
+ return first.message ?? `could not fully read ${first.path}`;
1458
+ }
1459
+
1460
+ /* -------------------------------------------------------------------------- */
1461
+ /* The status line: this request, and this conversation */
1462
+ /* -------------------------------------------------------------------------- */
1463
+
1464
+ /**
1465
+ * The two numbers that are about the conversation you are in.
1466
+ *
1467
+ * Separate from the strip above because they answer a different question and change on a different
1468
+ * clock: these move with every turn in *this* conversation, the plan moves with every turn in every
1469
+ * conversation on the account.
1470
+ */
1471
+ async function installFooter({ neosh, subscriptions }: PluginContext) {
1472
+ const refresh = async () => {
1473
+ const session = await neosh.session.current().catch(() => null);
1474
+ if (!session) return;
1475
+ await drawContext(neosh, session);
1476
+ await drawTokens(neosh, session);
1477
+ };
1478
+
1479
+ await refresh();
1480
+ // Not only at turn end. An agent driver reports how full its context is while it works, and a
1481
+ // meter that waited for the turn to finish would be answering "should I start a new
1482
+ // conversation?" only once it was too late to matter for this one.
1483
+ subscriptions.push(
1484
+ neosh.agent.onActivity((e) => {
1485
+ if (e.activity.kind === "context" || e.activity.kind === "compacted") void refresh();
1486
+ }),
1487
+ );
1488
+ // Turn end is when the running total changes, and for a model driver the only time either can.
1489
+ subscriptions.push(neosh.agent.onTurnEnd(() => void refresh()));
1490
+ subscriptions.push(neosh.session.onChange(() => void refresh()));
1491
+ // The model is half of the meter: the same conversation is 40% of one window and 8% of another.
1492
+ // The *selection*, not the `agent.model` option — a model that resolves late, or one swapped out
1493
+ // because it could not authenticate, changes the denominator without anybody setting anything.
1494
+ subscriptions.push(neosh.agent.onSelectionChange(() => void refresh()));
1495
+ subscriptions.push(
1496
+ neosh.opt.onChange((e) => {
1497
+ if (e.name === "usage.show_tokens" || e.name === "ui.ascii_only") void refresh();
1498
+ }),
1499
+ );
1500
+ }
1501
+
1502
+ /**
1503
+ * The window those tokens are a fraction of.
1504
+ *
1505
+ * The driver's own figure first, and it is not a close call. A catalogue says what a *model* has;
1506
+ * a vendor CLI's window is whatever that CLI decided, and the two disagree — `claude` running
1507
+ * `claude-haiku-4-5` reports 200k where the catalogue entry for the selected model said a million.
1508
+ * Dividing by the wrong one is how a meter reads `2%` where the agent itself would say `8%`.
1509
+ *
1510
+ * The catalogue is the fallback, for a model driver and for a conversation that has not run a turn
1511
+ * yet. Nothing at all is the third case, and then the number is shown without a percentage rather
1512
+ * than against a denominator somebody made up.
1513
+ */
1514
+ async function contextWindow(neosh: Neosh, session: SessionInfo): Promise<number | undefined> {
1515
+ if (session.context_window) return session.context_window;
1516
+ const selection = await neosh.agent.selection().catch(() => null);
1517
+ if (!selection) return undefined;
1518
+ const entries = await neosh.agent
1519
+ .listModels(selection.instance)
1520
+ .catch(() => [] as ModelEntry[]);
1521
+ const model = entries.find((e) => e.model.id === selection.model);
1522
+ return model?.model.context_window ?? undefined;
1523
+ }
1524
+
1525
+ /**
1526
+ * The context meter: a bar, then the percentage, then what it is a percentage of.
1527
+ *
1528
+ * Drawn even at zero, which is the change that matters. It used to appear only once a turn had
1529
+ * been spent, so the one moment you could not see how much room a model has was *before* choosing
1530
+ * what to do with it — and a model with a 200k window and one with a million are different tools.
1531
+ *
1532
+ * One colour for the whole segment rather than a lit part and a dim part: a status segment carries
1533
+ * one highlight, and two glyphs of very different weight already say where the fill ends. The
1534
+ * colour is then free to say the thing colour is good at, which is how worried to be.
1535
+ */
1536
+ async function drawContext(neosh: Neosh, session: SessionInfo) {
1537
+ const used = session.context_tokens ?? 0;
1538
+ const window = await contextWindow(neosh, session);
1539
+ // Nothing to be a percentage of. Better to say the number than to invent a denominator.
1540
+ if (!window) {
1541
+ if (used > 0) {
1542
+ // Same rank as the meter below: it is the same fact, said without a denominator because
1543
+ // there is not one to be had.
1544
+ await neosh.status.set("context", { text: `${short(used)} ctx`, priority: 8 });
1545
+ } else {
1546
+ await neosh.status.clear("context");
1547
+ }
1548
+ return;
1549
+ }
1550
+ const ascii = (await neosh.opt.get<boolean>("ui.ascii_only").catch(() => false)) ?? false;
1551
+ const fraction = Math.min(1, used / window);
1552
+ const percent = fraction * 100;
1553
+ const bar = meter(fraction, CELLS, { ascii });
1554
+ await neosh.status.set("context", {
1555
+ text: `${bar} ${percent.toFixed(0)}% of ${short(window)}`,
1556
+ // What to give up when the strip runs out of room: the denominator. It is the one part of
1557
+ // this that does not change all conversation — the window is a property of the model, and it
1558
+ // is on the model picker, in `/usage` and in the sidebar — whereas the bar and the percentage
1559
+ // are the whole reason anybody looks here. Not a truncation: `███░░░░░ 34%` is true.
1560
+ short: `${bar} ${percent.toFixed(0)}%`,
1561
+ hl: percent > 90 ? "Meter.Full" : percent > 70 ? "Meter.Warn" : "Comment",
1562
+ // Ahead of the branch, the cost and the token counts, and this is the point rather than a
1563
+ // detail of the ordering. Priority is also what a narrow strip gives up first, and this used
1564
+ // to sit on 20 — tied with the git branch, separated only by the fact that "context" sorts
1565
+ // after "branch" — which made it the second thing out of the line after the token counts. So
1566
+ // on a terminal a few columns short, the meter vanished; and because a running turn adds its
1567
+ // own segment to the right-hand end, a terminal wide enough at rest was several columns too
1568
+ // narrow the moment an answer started, which is precisely when the number matters. It is the
1569
+ // most valuable thing on this strip: it is the one that says whether the conversation is
1570
+ // about to stop working.
1571
+ priority: 8,
1572
+ });
1573
+ }
1574
+
1575
+ async function drawTokens(neosh: Neosh, session: SessionInfo) {
1576
+ const on = (await neosh.opt.get<boolean>("usage.show_tokens").catch(() => true)) ?? true;
1577
+ const u = session.usage;
1578
+ const total = u.input_tokens + u.output_tokens;
1579
+ if (!on || total === 0) {
1580
+ await neosh.status.clear("tokens");
1581
+ return;
1582
+ }
1583
+ await neosh.status.set("tokens", {
1584
+ text: `↑${short(u.input_tokens)} ↓${short(u.output_tokens)}`,
1585
+ priority: 21,
1586
+ });
1587
+ }
1588
+
1589
+ /** A token count at a glance: `1.2k`, `340k`, `1.4M`. */
1590
+ function short(n: number): string {
1591
+ if (n < 1000) return String(n);
1592
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
1593
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
1594
+ return `${(n / 1_000_000).toFixed(1)}M`;
1595
+ }
1596
+
1597
+ /**
1598
+ * The long version, for when the footer's two numbers are not enough.
1599
+ *
1600
+ * Cache reads are broken out because they are the difference between a conversation that is
1601
+ * expensive and one that merely looks it: a turn whose prompt is 90% cache read costs a tenth of
1602
+ * what the raw input number suggests.
1603
+ */
1604
+ async function report(neosh: Neosh): Promise<void> {
1605
+ const session = await neosh.session.current().catch(() => null);
1606
+ if (!session) {
1607
+ neosh.notify("no conversation", "warn");
1608
+ return;
1609
+ }
1610
+ const u = session.usage;
1611
+ const window = await contextWindow(neosh, session);
1612
+ const used = session.context_tokens ?? 0;
1613
+ const lines = [
1614
+ `context ${short(used)}${window ? ` of ${short(window)} (${((used / window) * 100).toFixed(1)}%)` : ""}`,
1615
+ `input ${short(u.input_tokens)}`,
1616
+ `output ${short(u.output_tokens)}`,
1617
+ `cache ${short(u.cache_read_tokens)} read, ${short(u.cache_write_tokens)} written`,
1618
+ ];
1619
+ if (u.thinking_tokens > 0) lines.push(`thinking ${short(u.thinking_tokens)}`);
1620
+ neosh.notify(lines.join(" · "));
1621
+ }