From 859d9244e8095910428333f6dbc7cce75f0d795e Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 17:17:54 +0200 Subject: [PATCH] feat(usage): report per-model plan limits in their own bar row Fable usage is reported. Per-model windows arrive in rate_limits.model_scoped, an array alongside the keyed windows, which parseUsageReport walked past entirely. Reading it is necessary and not sufficient: the binary does not relay that key, it synthesises it behind its own remote config (IUt(limits, jJe()) returns an empty list when tengu_usage_overage_included_models is empty, and the key is spliced in only when the projection yielded something), so in a --print session it never arrived. The raw rate_limits.limits[] array it projects from does arrive untouched, so the weekly_scoped entries that name a model are read from there too, with the binary's filter and without its allowlist -- that list selects overage billing, not which limits meter you. resets_at is epoch seconds there as often as a string and is normalised rather than deserialized. A refresh is now merged into the last one instead of replacing it. loadPlanRateLimits gives the usage endpoint 5s and falls back to seedUtilization(), an object rebuilt from the rate-limit response headers that can only carry five_hour and seven_day; it is flagged "seeded" and accepted identically downstream, so a failed poll was indistinguishable from one saying the per-model window is gone, and the bar blinked out and back. Merged by key over the whole set, since the opus and sonnet windows are missing from a seeded object for the same reason. The extra-credit balance is deliberately not carried. The plan limits moved out of the readout into their own responsive row under the status line, one labelled bar per window. Inline they trailed a wrapping row of unrelated metrics, so the windows nearest their cap were the first to wrap out of sight. Also in this cycle: an "Other models" group in the picker holding previous generations, with set_model sent as a correlated control request so a refusal restores the previous model instead of leaving the tab pointed at one every turn would fail on; the nimbus_quill window hidden on both ingestion paths rather than only the report; and quota notifications titled through UsageWindow.title(key) so a per-model window announces "Fable", not its synthetic key. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 105 ++++++ RELEASE_NOTES.md | 25 ++ build.gradle.kts | 2 +- .../dev/lain/claudejb/protocol/Protocol.kt | 218 +++++++++++- .../lain/claudejb/session/ClaudeSession.kt | 58 ++- .../dev/lain/claudejb/session/LegacyModels.kt | 67 ++++ .../claudejb/session/SessionControlClient.kt | 23 +- .../dev/lain/claudejb/ui/JcefChatPanel.kt | 6 +- .../lain/claudejb/ui/jcef/JcefSessionData.kt | 15 +- .../dev/lain/claudejb/ui/jcef/JcefState.kt | 42 ++- src/main/resources/jcef/app-composer.js | 139 ++++++-- src/main/resources/jcef/app.css | 108 +++++- src/test/frontend/model-menu.test.js | 85 +++++ src/test/frontend/readout.test.js | 68 ++++ .../lain/claudejb/protocol/UsageReportTest.kt | 336 ++++++++++++++++++ .../lain/claudejb/session/LegacyModelsTest.kt | 85 +++++ 16 files changed, 1286 insertions(+), 96 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/session/LegacyModels.kt create mode 100644 src/test/frontend/model-menu.test.js create mode 100644 src/test/kotlin/dev/lain/claudejb/session/LegacyModelsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ee0047..c64b57a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,111 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.1.0] — 2026-08-10 + +### Added +- **An "Other models" group in the model picker**, holding previous generations (Opus 4.8 → 4.0, Sonnet 4.6 → + 4.0, Sonnet 3.7 and 3.5, Haiku 3.5). Collapsed by default so the four current models keep the menu they had, + and expanded automatically when the selected model lives inside it. + + The list is **curated in the plugin**, which deserves stating plainly because this repository removed a + hardcoded model label in 4.3.3. There is no runtime source for it: the binary's selectable catalog — the + `initialize` reply, and the identical answer to the `list_models` control request — contains only the current + generation, and `ModelInfo` carries no `deprecated`/`legacy` flag. The binary still *accepts* these ids, it + just will not list them. The distinction that makes a curated list defensible here: these are **historical** + ids, which never change and never disappear, so the list can only gain entries. What went stale in 4.3.3 was + a label describing the *current* tier. Nothing here names a current model, and a test enforces that. + + Choosing a model the account cannot run is handled rather than left to fail: `set_model` is now sent as a + **correlated** control request, and a refusal restores the previous model and says so in the transcript + instead of leaving the tab pointed at a model every later turn would fail on. + +- **Per-model plan limits — Fable among them — are reported.** `get_usage` returns them in + `rate_limits.model_scoped`, an *array* alongside the keyed windows rather than another key inside them + (`sdk.d.ts`: `{ display_name, utilization, resets_at }[]`, and its own example names `'Fable'`). + `parseUsageReport` walked only the keyed windows, so every per-model figure the server sent was dropped on + the floor — which is why the CLI's `/usage` showed a Fable row the plugin never did. + + Reading that array is necessary and **not sufficient**, which is what the first attempt got wrong: the + binary does not relay `model_scoped`, it *synthesises* it, and only behind its own remote config. Its + projection (`IUt(limits, jJe())` in 2.1.223) reads the `tengu_usage_overage_included_models` gate, returns + an empty list the moment that gate is empty, and the key is spliced into `rate_limits` only when the + projection yielded something — so in a `--print` session it simply never arrived, which is why the plugin + logged `five_hour` and `seven_day` and nothing else while the same account's interactive `/usage` listed + Fable. The plugin therefore also walks the **raw `rate_limits.limits[]` array the projection reads from**, + which does ride through untouched — the binary's own `/usage` formatter assumes as much, calling `IUt` on + this very payload — taking the `weekly_scoped` entries that name a model, with the binary's filter and + without its allowlist. Dropping the allowlist is deliberate: it selects which models get *overage billing*, + not which limits a user is subject to, and a limit that meters you is worth showing whether or not you can + pay past it. `resets_at` is epoch seconds there as often as a string, so it is normalised rather than + deserialized — a numeric one would have failed to decode and dropped the whole window in silence. + + And a usage refresh is now **merged** into the last one instead of replacing it, because the same fetch has + a second fallback that omits windows: `loadPlanRateLimits` gives `/api/oauth/usage` 5 s, and on a timeout, a + 429 or a fieldless body it substitutes `seedUtilization()` — an object rebuilt from the rate-limit *response + headers*, which structurally carries only `five_hour` and `seven_day`. It is flagged `status:"seeded"` and + then accepted identically to a full reply, so a poll that simply failed was indistinguishable from one + saying the per-model window no longer exists — and the Fable bar blinked out and back every few polls. + Merged by window key over the whole set, since `seven_day_opus`/`seven_day_sonnet` are missing from a seeded + object for the same reason and would flicker the same way; a carried-forward window keeps the last figure + actually reported for it and the next real refresh overwrites it. The extra-credit balance is deliberately + not carried: `null` there already means "this plan has none" as often as "this reply did not say". + + They are keyed `model_scoped:` because the quota-crossing record is kept per window and has to + stay stable across refreshes, and titled from the server's own `display_name` — the *only* source for it, + since nothing in the plugin can name a window the server invents. An entry whose name collides with a keyed + window is dropped rather than duplicated, one missing a name or a figure is skipped, and they sort after the + known windows so the row order the user already reads does not shuffle when Anthropic adds a model. + +### Removed +- **The `nimbus_quill` usage window is no longer shown.** The claude.ai usage endpoint emits it and the CLI + relays it untouched; it appears in no version of the binary and in no SDK type, so nothing here can say what + it meters — it rendered as "Nimbus quill 0.0%", a row that asks a question and answers none. Hidden **by + name**, deliberately not by a general "hide unknown windows" rule, which would silently swallow the next + real limit; the moment it means something, deleting one line brings it back with its label, bar and ordering + intact. + + It kept appearing anyway, because the filter sat on one of the **two** paths that feed a window to the UI: + the `get_usage` report was filtered, the `rate_limit_event` stream was not, and that is the door it was + arriving through. The rule is now applied on both (`isHiddenUsageWindow`), and on the event path the window + is dropped whole rather than merely hidden — it must not become the session's `rateLimit` either, which + drives the single-number quota bar. + +### Fixed +- **A quota notification announcing 100% when almost nothing had been used.** `get_usage` reports each + window as a percentage on a 0–100 scale — `sdk.d.ts` says so on every window, and a live reply from + `claude` 2.1.222 carries `8` and `67`. `ClaudeSession` held a private copy of an "the wire sends both + 0–100 and 0–1, accept either" heuristic that multiplied any value `<= 1.0` by a hundred. So a window at a + genuine **1%** was reported as **100%**, crossed the 85% threshold, and raised an IDE notification telling + the user their plan was spent — at the moment they had spent almost none of it, which is to say right + after a window resets. The heuristic is undecidable at exactly 1.0 by construction: it cannot tell a full + window from a barely-touched one. + + The rule now lives once, on the model (`UsageWindow.utilizationPercent()`), with no scale guessing: the + value is already a percentage. Two of the three copies had been removed in 5.0.1 when the dashboard + stopped rounding; this was the third, and the only one wired to notifications, which is why the bars got + quieter while the notifications kept shouting. The event path (`RateLimitInfo.utilization`, genuinely a + 0..1 fraction) is unchanged and was never affected. + +### Changed +- **The plan limits are their own row under the status line**, one labelled bar per window, instead of dots + at the end of the readout. Inline, they sat behind `Running… / Context 65% / 65.3k out / 0 reasoning` on a + wrapping row — so the windows *nearest their cap*, the ones the row exists for, were the ones most likely to + wrap out of sight in a narrow tool window. The row is a `repeat(auto-fit, minmax(150px, 1fr))` grid: it + spends the full width at any size and drops to fewer columns as the panel narrows, with no media query and + no fixed layout to outgrow. The bar is clamped to 100%; the number is not, because a window reported past + its cap is exactly the figure worth reading. + +- Quota notifications title themselves through `UsageWindow.title(key)` rather than from the key, so a + per-model window announces "Fable quota at 85%" instead of the synthetic `model_scoped:Fable`. The record + that decides whether a threshold has already been announced stays keyed by the key, which is what makes it + survive a refresh. + +- The `get_usage` path now logs each window's raw utilization and the percentage derived from it, at INFO. + When the false 100% was reported there was nothing in `idea.log` to check it against, because only the + *event* path carried a trace — and that one is `debug`, so it is off by default. A number the user can see + should leave behind the value that produced it. + ## [5.0.1] — 2026-08-10 ### Fixed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 79d56e6c..a6995677 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,28 @@ +## v5.1.0 — 2026-08-10 + +**Older models are selectable again.** The model picker has an **Other models** group with the previous +generations — Opus 4.8 through 4.0, Sonnet 4.6 through 4.0, Sonnet 3.7 and 3.5, Haiku 3.5. It stays collapsed +so the current models are still one click away, and opens on its own if you have an older model selected. If +your plan doesn't include the one you pick, the plugin tells you and puts your previous model back rather than +leaving the chat stuck on something every message would fail on. + +**Your plan limits now sit in their own row, right under the status line** — one labelled bar per window +(*Current session*, *All models*, and any per-model limit), blue while you have room, amber as you get close, +red near the cap. They used to be small dots tacked onto the end of the status line, which meant that in a +narrow tool window the limits closest to running out were the first to wrap out of sight. The row now takes +the full width of the panel whatever size you've dragged it to, and reflows instead of overflowing. + +**Fable usage is reported.** Per-model limits — Fable's among them — live in a separate list the plugin was +walking past, so `claude`'s own `/usage` showed a Fable row and the plugin showed nothing. They're now read +and shown like any other window, under the name the API gives them, whether or not the CLI decides to +pre-package them for us. + +**No more "quota at 100%" when you have barely used any.** A window that was genuinely at 1% was being read +as if it were full, which tripped the near-the-limit warning and popped an IDE notification saying your plan +was spent. It fired most reliably right after a limit window resets — the moment you have the *most* quota +left. The percentage is now read on the scale the API actually sends, and the dashboard and the notification +can no longer disagree. + ## v5.0.1 — 2026-08-10 **You should stop having to sign in every morning.** Your login was being stored properly all along — in your diff --git a/build.gradle.kts b/build.gradle.kts index 1aac9fae..883128c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.0.1" +version = "5.1.0" repositories { mavenCentral() diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt index 28538b0b..f535dfaa 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt @@ -346,7 +346,44 @@ data class UsageWindow( @SerialName("limit_dollars") val limitDollars: Double? = null, @SerialName("used_dollars") val usedDollars: Double? = null, @SerialName("remaining_dollars") val remainingDollars: Double? = null, -) + // Only the `model_scoped` entries carry this. It is the SERVER's own label for the bucket ("Fable"), and + // the only thing that names them: their key is synthesised here, so there is nothing to derive a title + // from the way `seven_day_opus` derives "Opus". + @SerialName("display_name") val displayName: String? = null, +) { + /** + * How this window is titled in the UI: the server's own label when it sent one, else the key's. + * + * Every surface (dashboard bar, composer dot, quota warning) goes through here, so a per-model window + * cannot end up correctly labelled in one place and titled from its synthetic key in another. + */ + fun title(key: String): String = + displayName?.takeIf { it.isNotBlank() } ?: RateLimitInfo.windowTitleFor(key) + + /** + * The window's percentage, 0..100, or null when the binary reported none. + * + * **THE SCALE IS ALREADY A PERCENTAGE HERE, and this is the whole point of the function existing.** + * `sdk.d.ts` documents every `get_usage` window as *"Percentage of the window used, 0-100"* — unlike + * [RateLimitInfo.utilization] on the *event*, which is a 0..1 fraction. The two really do differ, and + * conflating them is not a rounding error but a factor of a hundred in either direction. + * + * There is deliberately **no "accept 0..1 too" heuristic**. It is undecidable at exactly 1.0, and it + * decided wrong: a window at a genuine **1%** was read as a fraction and reported as **100%**, which + * fired the 85% quota notification — telling the user their plan was spent at the moment they had spent + * almost none of it. Reachable by every user at the start of every freshly reset window, and it survived + * as a private copy in `ClaudeSession` after the same rule had been removed from the two display paths. + * Hence one function, on the model, for every caller. + */ + fun utilizationPercent(): Int? = + utilization?.let { Math.round(it).toInt().coerceIn(0, PERCENT) } + + // NOT a private companion: kotlinx generates `serializer()` ON the companion, and `parseUsageReport` + // calls `UsageWindow.serializer()` by hand — making it private hides the generated accessor with it. + companion object { + private const val PERCENT = 100 + } +} /** `rate_limits.extra_usage` — the pay-as-you-go credit balance shown once the plan's windows are spent. */ @Serializable @@ -381,13 +418,46 @@ data class UsageReport( /** Windows first in this order; anything the binary adds later sorts after, in the order it sent them. */ private val USAGE_WINDOW_ORDER = listOf("five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet") +/** + * Windows dropped before they reach any surface — dashboard bar, composer dot or quota warning. + * + * **This is a deliberate exception to the rule right below it**, which is that an unknown window is still a + * limit the user is subject to and gets shown with a derived label rather than hidden. `nimbus_quill` is a + * key the claude.ai usage endpoint emits and the CLI relays untouched — it appears in no version of the + * binary (grepped: zero occurrences) and in no SDK type, so nothing here can say what it meters. It rendered + * as "Nimbus quill 0.0%", which is a row that asks a question and answers none. + * + * **Temporary, and keyed by name so it stays cheap to undo**: the moment that window means something to a + * user, delete the entry and it comes back with everything else it already has — ordering, label, bar. + * Deliberately NOT generalised into "hide unknown windows", which would silently swallow the next real limit + * Anthropic ships. + * + * Applied on BOTH paths that feed a window to the UI — the `get_usage` report here and the `rate_limit_event` + * stream in `ClaudeSession.onRateLimit`, which is why it is public. Filtering only the report left the row on + * screen anyway, arriving by the other door. + */ +private val HIDDEN_WINDOWS = setOf("nimbus_quill") + +/** Whether [window] is one of the [HIDDEN_WINDOWS] the UI never shows. */ +fun isHiddenUsageWindow(window: String?): Boolean = window in HIDDEN_WINDOWS + +/** + * Prefix of the synthetic key given to a `model_scoped` window, whose real identity is its `display_name`. + * + * Unlike every other window, these arrive in a JSON **array** with no key of their own, so one is made here. + * It has to be stable across refreshes — the "announce a quota crossing once" record is kept per key — and it + * has to be namespaced, so a bucket the server one day labels "Overage" cannot collide with the top-level + * window of that name. + */ +const val MODEL_SCOPED_KEY_PREFIX = "model_scoped:" + /** * Parses a `get_usage` reply. Returns null when the payload is absent or carries nothing worth showing. * - * `rate_limits` is a heterogeneous object — window entries, explicit nulls for untouched windows, and - * `extra_usage` with an entirely different shape — so it is walked by hand rather than deserialized whole. A - * window that fails to decode is skipped, never thrown on: this feeds a dashboard, and one unrecognised key - * from a newer binary must not blank the whole panel. + * `rate_limits` is a heterogeneous object — window entries, explicit nulls for untouched windows, the + * `model_scoped` ARRAY, and `extra_usage` with an entirely different shape — so it is walked by hand rather + * than deserialized whole. A window that fails to decode is skipped, never thrown on: this feeds a dashboard, + * and one unrecognised key from a newer binary must not blank the whole panel. */ fun parseUsageReport(payload: JsonObject?): UsageReport? { payload ?: return null @@ -395,16 +465,13 @@ fun parseUsageReport(payload: JsonObject?): UsageReport? { val extra = (limits?.get("extra_usage") as? JsonObject)?.let { runCatching { ClaudeJson.decodeFromJsonElement(ExtraUsage.serializer(), it) }.getOrNull() } - val windows = limits.orEmpty().mapNotNull { (key, value) -> - if (key == "extra_usage") return@mapNotNull null + val keyed = limits.orEmpty().mapNotNull { (key, value) -> + if (key == "extra_usage" || key == MODEL_SCOPED) return@mapNotNull null + if (key in HIDDEN_WINDOWS) return@mapNotNull null val obj = value as? JsonObject ?: return@mapNotNull null // null = window exists but untouched - runCatching { ClaudeJson.decodeFromJsonElement(UsageWindow.serializer(), obj) } - .getOrNull() - ?.takeIf { it.utilization != null || it.usedDollars != null } - ?.let { key to it } - }.sortedBy { (key, _) -> - USAGE_WINDOW_ORDER.indexOf(key).takeIf { it >= 0 } ?: USAGE_WINDOW_ORDER.size + decodeWindow(obj)?.let { key to it } } + val windows = sortUsageWindows(keyed + perModelWindows(limits, keyed)) val report = UsageReport( subscriptionType = payload.str("subscription_type"), available = (payload["rate_limits_available"] as? JsonPrimitive)?.booleanOrNull ?: false, @@ -414,6 +481,131 @@ fun parseUsageReport(payload: JsonObject?): UsageReport? { return report.takeUnless { it.isEmpty } } +/** Known windows lead, in [USAGE_WINDOW_ORDER]; anything else trails in the order it arrived (stable sort). */ +private fun sortUsageWindows(windows: List>): List> = + windows.sortedBy { (key, _) -> + USAGE_WINDOW_ORDER.indexOf(key).takeIf { it >= 0 } ?: USAGE_WINDOW_ORDER.size + } + +/** + * This report over a [previous] one: fresh windows win, windows it does not mention are carried forward. + * + * **A refresh that omits a window is not a claim that the window is gone**, and treating it as one made the + * per-model bars blink in and out every poll. The cause is in the binary and is structural: `loadPlanRateLimits` + * fetches `/api/oauth/usage` with a 5 s timeout, and on a timeout, a 429 or a fieldless body it falls back to + * `seedUtilization()` — an object rebuilt from the rate-limit *response headers*, which by construction can + * only carry `five_hour` and `seven_day`. The reply is then flagged `status:"seeded"`, and `collectUsageData` + * accepts `"ok"` and `"seeded"` identically, so a seeded refresh is indistinguishable downstream from a full + * one that genuinely has no per-model window. Verified against `claude` 2.1.223. + * + * So the merge is by window key, over the whole set rather than only the per-model ones: `seven_day_opus` and + * `seven_day_sonnet` are absent from a seeded object for exactly the same reason and would flicker exactly the + * same way. A carried-forward window keeps the last figure that was actually reported for it, and the next + * unseeded refresh overwrites it — within one session, which is the only lifetime this holds for. + * + * [UsageReport.extra] is deliberately NOT carried: `null` there already means "the plan has no extra-credit + * balance" as often as it means "this reply did not say", and inventing the distinction would keep a stale + * balance on screen after the user turns the feature off. + */ +fun UsageReport.mergedOver(previous: UsageReport?): UsageReport { + val earlier = previous?.windows.orEmpty() + if (earlier.isEmpty()) return this + val present = windows.mapTo(mutableSetOf()) { it.first } + val carried = earlier.filterNot { (key, _) -> key in present } + return if (carried.isEmpty()) this else copy(windows = sortUsageWindows(windows + carried)) +} + +private const val MODEL_SCOPED = "model_scoped" + +/** The raw per-limit array the binary's own per-model projection reads from, relayed to us untouched. */ +private const val RAW_LIMITS = "limits" + +/** The `kind` that marks a raw entry as a per-model weekly window. */ +private const val WEEKLY_SCOPED = "weekly_scoped" + +private fun decodeWindow(obj: JsonObject): UsageWindow? = + runCatching { ClaudeJson.decodeFromJsonElement(UsageWindow.serializer(), obj) } + .getOrNull() + // A window with neither a percentage nor a dollar figure has nothing to draw. Deliberately NOT the + // same as absent: the binary sends explicit nulls for limits that exist but have not been touched. + ?.takeIf { it.utilization != null || it.usedDollars != null } + +/** + * The per-model weekly windows, the only ones that name the model they meter — "Fable" is reported here and + * nowhere else, so without this it simply does not exist for the user. + * + * **Two sources, and the second one is the load-bearing one.** The binary offers a ready-made `model_scoped` + * array, but it emits it only when its own remote config says so: `LCn()` projects the windows through + * `IUt(limits, jJe())`, where `jJe()` reads the `tengu_usage_overage_included_models` gate and `IUt` returns + * an EMPTY list the moment that gate is empty — and `rate_limits` then carries no `model_scoped` key at all. + * Verified against `claude` 2.1.223 (the projection, its gate, and the `i.length > 0` condition that decides + * whether the key is spliced in), and confirmed live: the plugin's `--print` session logged `five_hour` and + * `seven_day` and never a per-model window, while the same account's interactive `/usage` listed Fable. + * + * So the raw array the projection reads from — `rate_limits.limits[]`, which rides through untouched, as the + * binary's own `/usage` formatter assumes when it calls `IUt(t.limits, …)` on this very payload — is walked + * here as well: `kind == "weekly_scoped"` entries with a `scope.model.display_name`, exactly the filter the + * binary applies, minus its allowlist. Dropping the allowlist is the point: it decides which models get + * *overage* billing, not which limits a user is subject to, and a limit that meters you is worth showing + * whether or not you can pay past it. + * + * `model_scoped` still wins where both carry a model, since it is the server's own projection; the raw array + * fills in the rest. Overlap with `seven_day_opus`/`seven_day_sonnet` is expected rather than exceptional + * (the SDK calls these windows *additive*), so an entry whose label already titles one of [alreadyKeyed] is + * dropped — two bars reading "Opus" is worse than one, and the keyed window is the one whose meaning the + * plugin knows. An entry with no name is skipped: its key is synthesised from that name, so a blank one has + * neither identity nor title and would render as the same unanswerable bar `nimbus_quill` was hidden for. + */ +private fun perModelWindows( + limits: JsonObject?, + alreadyKeyed: List>, +): List> { + val taken = alreadyKeyed.mapTo(mutableSetOf()) { (key, w) -> w.title(key).lowercase() } + val candidates = modelScopedEntries(limits) + rawWeeklyScopedEntries(limits) + return candidates.mapNotNull { (name, window) -> + if (!taken.add(name.lowercase())) return@mapNotNull null + "$MODEL_SCOPED_KEY_PREFIX$name" to window + } +} + +/** The binary's own projection, `rate_limits.model_scoped` — present only when its remote gate is set. */ +private fun modelScopedEntries(limits: JsonObject?): List> { + val entries = limits?.get(MODEL_SCOPED) as? JsonArray ?: return emptyList() + return entries.mapNotNull { element -> + val window = decodeWindow(element as? JsonObject ?: return@mapNotNull null) ?: return@mapNotNull null + val name = window.displayName?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + name to window + } +} + +/** + * The same windows read from the raw `rate_limits.limits[]` array the binary projects from. + * + * Field names differ from every other window here and are taken from the binary, not guessed: the percentage + * is `percent` (already 0–100 — its `/usage` formatter prints `Math.floor(utilization)%` straight from it) and + * `resets_at` is epoch **seconds** as often as it is a string, which is why it is normalised rather than + * handed to the deserializer: a numeric one would fail to decode and silently drop the whole window. + */ +private fun rawWeeklyScopedEntries(limits: JsonObject?): List> { + val entries = limits?.get(RAW_LIMITS) as? JsonArray ?: return emptyList() + return entries.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + if (obj.str("kind") != WEEKLY_SCOPED) return@mapNotNull null + val model = (obj["scope"] as? JsonObject)?.get("model") as? JsonObject + val name = model?.str("display_name")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val percent = (obj["percent"] as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull() + ?: return@mapNotNull null + name to UsageWindow(utilization = percent, resetsAt = isoResetsAt(obj["resets_at"]), displayName = name) + } +} + +/** `resets_at` as ISO-8601, converting the epoch-seconds form the raw `limits[]` entries use. */ +private fun isoResetsAt(value: kotlinx.serialization.json.JsonElement?): String? { + val raw = (value as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null + val epochSeconds = raw.toLongOrNull() ?: return raw + return runCatching { java.time.Instant.ofEpochSecond(epochSeconds).toString() }.getOrNull() +} + private fun JsonObject?.orEmpty(): Map = this ?: emptyMap() // --------------------------------------------------------------------------- diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index bf24dc77..1524dc1b 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -35,6 +35,7 @@ import dev.lain.claudejb.protocol.RateLimitInfo import dev.lain.claudejb.protocol.SlashCommand import dev.lain.claudejb.protocol.TaskProgressInfo import dev.lain.claudejb.protocol.UsageReport +import dev.lain.claudejb.protocol.isHiddenUsageWindow import dev.lain.claudejb.protocol.parseElicitationFields import dev.lain.claudejb.protocol.parseUsageReport import dev.lain.claudejb.protocol.str @@ -1462,8 +1463,39 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : // "default" to the preferred concrete model so both the display and what's sent to the binary agree; // null stays null (unset — the Init handler fills it from the binary's reported model). val resolved = if (value == RECOMMENDED_ALIAS) preferredDefaultModel() else value + val previous = model model = resolved - if (isRunning()) write(ControlProtocol.setModelRequest(ControlProtocol.newRequestId(), resolved)) + if (isRunning()) { + // Correlated, not fire-and-forget, because "Other models" can offer a model this ACCOUNT cannot + // run (the list is curated from ids the binary knows — it cannot know what the plan grants). A + // refusal that only changed the pill would leave the tab pointed at a model every later turn + // fails on, with nothing saying why. + controlClient.send({ id -> ControlProtocol.setModelRequest(id, resolved) }) { res -> + if (!res.success) edt { revertModel(previous, resolved, res.error) } + } + } + fireState() + } + + /** + * Puts the previously selected model back after the binary refused a change, and says so in the transcript. + * + * EDT-only (it writes session state the UI reads). Silent when a newer selection has raced ahead of this + * reply — reverting then would undo a choice the user made after the failure. + * + * Scope, stated because it is narrower than it looks: this catches a refusal of the `set_model` control + * request itself. A binary that ACCEPTS the id and only fails later, when the turn reaches the API, is a + * different signal and arrives as an ordinary turn error. + */ + private fun revertModel(previous: String?, attempted: String?, error: String?) { + if (model != attempted) return + model = previous + // Labelled from the curated list, NOT from the UI layer: naming a model is not a rendering decision, + // and reaching into `ui.jcef` from here would invert the dependency this package deliberately keeps. + val name = attempted?.let { LegacyModels.labelFor(it) ?: it } ?: "That model" + val kept = previous?.let { LegacyModels.labelFor(it) ?: it } ?: "the previous model" + val reason = error?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty() + transcript.add(Speaker.SYSTEM, "$name is not available on this account$reason — kept $kept.") fireState() } @@ -1626,7 +1658,13 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : onResult = { report: UsageReport? -> edt { report?.windows?.forEach { (key, w) -> - w.utilization?.let { warnOnQuotaCrossing(key, normalizePercent(it)) } + // Logged at INFO, and not as noise: when this fired a false "quota at 100%" there was + // nothing in idea.log to check it against, because only the EVENT path was traced. + // A wrong number the user can see must leave the raw value behind that produced it. + w.utilizationPercent()?.let { pct -> + log.info("usage window $key: utilization=${w.utilization} -> $pct%") + warnOnQuotaCrossing(key, w.title(key), pct) + } } onResult(report) } @@ -1646,8 +1684,12 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : * 85% also raises an IDE notification, not just a transcript row. By then the user may be watching the * editor rather than the chat, and the point of the second threshold is that the wall is close enough to * change what they do next. + * + * [window] is the record's key and [label] what the user reads: they diverge for the per-model windows, + * whose key is synthesised (`model_scoped:Fable`) precisely because the server names them and nothing + * else does. Titling from the key would announce that synthetic string verbatim. */ - private fun warnOnQuotaCrossing(window: String, pct: Int) { + private fun warnOnQuotaCrossing(window: String, label: String, pct: Int) { val announced = quotaWarned[window] ?: 0 val crossed = QUOTA_THRESHOLDS.lastOrNull { pct >= it } ?: 0 if (crossed <= announced) { @@ -1656,7 +1698,6 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : return } quotaWarned[window] = crossed - val label = RateLimitInfo.windowTitleFor(window) val message = "$label quota at $pct%." systemNotice(message) if (crossed >= QUOTA_THRESHOLD_HIGH) notifyInfo(message) @@ -1665,10 +1706,6 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : /** Window → the highest threshold already announced for it. EDT-confined (written from the usage callback). */ private val quotaWarned = HashMap() - /** The wire has sent both 0..100 and 0..1; accept either and clamp. */ - private fun normalizePercent(raw: Double): Int = - (if (raw <= 1.0) raw * 100 else raw).toInt().coerceIn(0, 100) - fun requestSessionCost(onResult: (JsonObject?) -> Unit) { if (!isRunning()) { edt { onResult(null) } @@ -2153,6 +2190,11 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : " utilization=${incoming.utilization} -> pct=${incoming.utilizationPercent()}", ) val window = incoming.rateLimitType + // Hidden windows are dropped here rather than at each surface: this is the OTHER door into the same + // UI (the get_usage report is filtered in parseUsageReport), and it is the one "Nimbus quill 0.0%" + // kept coming through. Dropped whole — it must not become `rateLimit` either, which drives the + // single-number quota bar. + if (isHiddenUsageWindow(window)) return // The binary often emits a rate_limit_event without `utilization` (it's optional and only present when // the API returns it). Don't lose a previously-known utilization just because a later event omitted // it — carry it forward so the quota % stays shown once we've seen it. Carried forward PER WINDOW: diff --git a/src/main/kotlin/dev/lain/claudejb/session/LegacyModels.kt b/src/main/kotlin/dev/lain/claudejb/session/LegacyModels.kt new file mode 100644 index 00000000..06ec921f --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/LegacyModels.kt @@ -0,0 +1,67 @@ +package dev.lain.claudejb.session + +/** + * Previous-generation models, offered under "Other models" in the model picker. + * + * **Why this list exists in the plugin at all, which is the uncomfortable part.** The binary does not offer + * them: its selectable catalog — the one that arrives in the `initialize` reply, and the identical one behind + * the `list_models` control request — contains only the current generation (verified against `claude` 2.1.223: + * `default`, `opus[1m]`, `claude-fable-5[1m]`, `sonnet`, `haiku`, and nothing else). `ModelInfo` carries no + * `deprecated` or `legacy` flag either, so there is no runtime source to derive this from. It still ACCEPTS + * these ids on `--model` and `set_model` — they remain in its own model tables — it simply will not list them. + * + * So a curated list is the only way to put an older model in front of the user, and it is deliberately a list + * of **historical** ids. That distinction is what makes it maintainable: a released model id never changes and + * never disappears, so this file can only ever gain entries — unlike the hardcoded `"Default · Opus 4.8"` + * label removed in 4.3.3, which described the *current* tier and was wrong the moment the recommendation + * moved. Nothing here names the current generation, and nothing here is used as a default. + * + * Sourced from the ids the shipped binary itself still names, and verifiable in one command rather than from + * memory — `grep -ahoE "claude-(opus|sonnet|haiku)-[0-9][a-z0-9-]*" "$(readlink -f "$(which claude)")" | sort -u` + * over `claude` 2.1.223. Worth doing before adding an entry: an id invented from the version-numbering pattern + * looks right and is simply refused at `set_model` (there is no `claude-opus-4-2`, nor a `claude-sonnet-4-2`). + * Labels are written out rather than derived: + * [dev.lain.claudejb.ui.jcef.JcefState.deriveModelLabel] renders `claude-opus-4-7` correctly but turns + * `claude-3-5-sonnet` into "3 5 Sonnet", because the version leads the family in the 3.x naming scheme. + * + * An entry the account cannot use is not filtered here — we cannot know that without asking, and asking costs + * a turn. Selecting one that the plan refuses is handled where the refusal actually arrives: the session + * reverts to the previous model and says so (see [ClaudeSession.changeModel]). + */ +object LegacyModels { + + /** One selectable older model: the id sent to the binary, and how it is shown. */ + data class Entry(val value: String, val label: String) + + /** + * Newest first, grouped by family — the order they are shown in. Opus before Sonnet before Haiku, which + * is the order of the current catalog, so the submenu reads like the menu above it. + */ + val ALL: List = listOf( + Entry("claude-opus-4-8", "Opus 4.8"), + Entry("claude-opus-4-7", "Opus 4.7"), + Entry("claude-opus-4-6", "Opus 4.6"), + Entry("claude-opus-4-5", "Opus 4.5"), + Entry("claude-opus-4-1", "Opus 4.1"), + Entry("claude-opus-4-0", "Opus 4"), + Entry("claude-sonnet-4-6", "Sonnet 4.6"), + Entry("claude-sonnet-4-5", "Sonnet 4.5"), + Entry("claude-sonnet-4-0", "Sonnet 4"), + Entry("claude-3-7-sonnet", "Sonnet 3.7"), + Entry("claude-3-5-sonnet", "Sonnet 3.5"), + Entry("claude-3-5-haiku", "Haiku 3.5"), + ) + + /** The label for [value], or null when it is not one of ours — so callers can fall back to their own rule. */ + fun labelFor(value: String?): String? = value?.let { id -> ALL.firstOrNull { it.value == id }?.label } + + /** + * The entries worth offering given what the binary already lists, so a model can never appear twice. + * + * Matched on the id the CLI would resolve to as well as the row's own value: the catalog offers `sonnet` + * and `opus[1m]` as aliases, and a future catalog that starts listing a concrete older id must not end up + * rendering it in both places. + */ + fun offeredAlongside(catalog: Collection): List = + ALL.filterNot { entry -> catalog.any { it == entry.value } } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt index cc6418cc..7162a40c 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt @@ -87,6 +87,19 @@ class SessionControlClient( buildRequest: (requestId: String) -> String, onResult: (T?) -> Unit, decode: (JsonObject?) -> T?, + ) = send(buildRequest) { res -> onResult(decode(res.payload)) } + + /** + * Like [query], but hands the caller the RAW outcome instead of a decoded payload. + * + * The distinction is not cosmetic: [query] collapses "the binary refused" and "the binary agreed and had + * nothing to say" into the same `null`, because all it forwards is `decode(payload)`. For a request whose + * answer IS the success flag — `set_model`, where a refused model must be rolled back and an accepted one + * has no payload at all — that collapse would revert every successful change. + */ + fun send( + buildRequest: (requestId: String) -> String, + onOutcome: (ClaudeEvent.ControlResult) -> Unit, ) { val id = newRequestId() // Watchdog: a semi-stuck binary could otherwise leave this callback pending forever (eternal "Loading…"). @@ -98,15 +111,13 @@ class SessionControlClient( val requestLine = buildRequest(id) pending[id] = { res -> watchdog.cancel() - val decoded = decode(res.payload) - // The data-flow trace: what the binary ANSWERED and what our decode made of it. When a panel is - // empty, this line is the split between "the binary never sent it" and "we dropped it". + // The data-flow trace: what the binary ANSWERED. When a panel is empty, this line is the split + // between "the binary never sent it" and "we dropped it" (the decode happens in the caller). log.debug( "CC-TRACE control reply ${requestSubtype(requestLine)} id=$id success=${res.success}" + - " err=${res.error ?: "-"} payload=${res.payload?.toString()?.take(TRACE_MAX) ?: "null"}" + - " -> decoded=${decoded?.toString()?.take(TRACE_MAX) ?: "null"}", + " err=${res.error ?: "-"} payload=${res.payload?.toString()?.take(TRACE_MAX) ?: "null"}", ) - onResult(decoded) + onOutcome(res) } log.debug("CC-TRACE control send ${requestSubtype(requestLine)} id=$id") write(requestLine) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index eef06b14..23d77199 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -18,6 +18,7 @@ import dev.lain.claudejb.context.Attachment import dev.lain.claudejb.context.EditorContextProvider import dev.lain.claudejb.context.FilePickerHelper import dev.lain.claudejb.diff.DiffPresenter +import dev.lain.claudejb.protocol.mergedOver import dev.lain.claudejb.session.AttentionReason import dev.lain.claudejb.session.ClaudeSession import dev.lain.claudejb.session.SessionListener @@ -404,7 +405,10 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : lastUsageAt = now session.requestUsage { report -> if (report != null) { - lastUsage = report + // Merged, not replaced: when the binary's usage fetch falls back to its header-seeded object + // the reply carries only five_hour/seven_day, and taking it literally made the per-model bars + // (Fable's among them) blink out on that poll and back on the next. See `mergedOver`. + lastUsage = report.mergedOver(lastUsage) // BOTH surfaces, or they disagree. `lastUsage` feeds the dashboard bars (pushSession) AND the // composer's usage dots (pushMetaState → stateJson). Pushing only the dashboard left the dots // blank until some unrelated state change happened to re-push — so the same number appeared in diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt index b82719c9..95c0836f 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -87,12 +87,13 @@ object JcefSessionData { // EXPERIMENT (Lain's comma test): carry the decimals — do NOT round to Int — so we can see whether the // frontend renders a fractional percentage with a comma (locale formatting in play) or a dot. val fromReport = report?.windows?.map { (key, w) -> - Window(key, w.utilization, w.resetsAt, exhausted = false) + Window(key, w.title(key), w.utilization, w.resetsAt, exhausted = false) }.orEmpty() val fromEvents = session.rateLimits .filterKeys { key -> fromReport.none { it.key == key } } .map { (key, info) -> - Window(key, info.utilization?.let { it * 100 }, info.resetsAt?.let(::isoOf), info.isExhausted) + val pct = info.utilization?.let { it * 100 } + Window(key, RateLimitInfo.windowTitleFor(key), pct, info.resetsAt?.let(::isoOf), info.isExhausted) } val windows = fromReport + fromEvents if (windows.isEmpty() && report?.extra == null) return null @@ -104,7 +105,7 @@ object JcefSessionData { windows.forEach { w -> addJsonObject { put("key", w.key) - put("label", RateLimitInfo.windowTitleFor(w.key)) + put("label", w.label) put("pct", w.pct) put("resetsAt", w.resetsAt) put("exhausted", w.exhausted) @@ -116,7 +117,13 @@ object JcefSessionData { } } - private data class Window(val key: String, val pct: Double?, val resetsAt: String?, val exhausted: Boolean) + private data class Window( + val key: String, + val label: String, + val pct: Double?, + val resetsAt: String?, + val exhausted: Boolean, + ) /** The pay-as-you-go balance. Credits are minor units (`decimal_places`), not whole currency. */ private fun extraUsageJson(extra: ExtraUsage): JsonObject = buildJsonObject { diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt index 7b110b1f..8170401c 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -4,6 +4,7 @@ import dev.lain.claudejb.protocol.ModelInfo import dev.lain.claudejb.protocol.RateLimitInfo import dev.lain.claudejb.protocol.UsageReport import dev.lain.claudejb.session.ClaudeSession +import dev.lain.claudejb.session.LegacyModels import dev.lain.claudejb.session.PermissionMode import dev.lain.claudejb.session.StatusLineFormatter import dev.lain.claudejb.settings.Provider @@ -34,15 +35,17 @@ object JcefState { // EXPERIMENT (Lain's comma test): carry the raw decimals here too, so the composer readout does not // round to Int either — otherwise the decimal never shows and the test can't see a comma vs a dot. val fromReport = usage?.windows.orEmpty().mapNotNull { (key, w) -> - w.utilization?.let { key to it } + w.utilization?.let { Triple(key, w.title(key), it) } } val fromEvents = session.rateLimits .filterKeys { key -> fromReport.none { it.first == key } } - .mapNotNull { (key, info) -> info.utilization?.let { key to it * 100 } } - (fromReport + fromEvents).forEach { (key, pct) -> + .mapNotNull { (key, info) -> + info.utilization?.let { Triple(key, RateLimitInfo.windowTitleFor(key), it * 100) } + } + (fromReport + fromEvents).forEach { (key, label, pct) -> addJsonObject { put("key", key) - put("label", RateLimitInfo.windowTitleFor(key)) + put("label", label) put("pct", pct) } } @@ -157,15 +160,26 @@ object JcefState { put( "options", buildJsonArray { - session.models - .filter { it.value != ClaudeSession.RECOMMENDED_ALIAS } - .forEach { m -> - addJsonObject { - put("value", m.value) - put("label", modelDisplayLabel(m)) - put("selected", m.value == selectedModel) - } + val catalog = session.models.filter { it.value != ClaudeSession.RECOMMENDED_ALIAS } + catalog.forEach { m -> + addJsonObject { + put("value", m.value) + put("label", modelDisplayLabel(m)) + put("selected", m.value == selectedModel) } + } + // Previous generations, tagged so the composer can fold them into an "Other models" submenu + // instead of burying the four current models in a list of seventeen. The GROUPING is decided + // here, not in the web app: the host owns which models exist and what they are called, and a + // frontend that had to recognise "old" ids would be a second, divergent copy of that rule. + LegacyModels.offeredAlongside(catalog.map { it.value }).forEach { entry -> + addJsonObject { + put("value", entry.value) + put("label", entry.label) + put("selected", entry.value == selectedModel) + put("group", "other") + } + } }, ) } @@ -317,6 +331,10 @@ object JcefState { fun modelLabel(session: ClaudeSession): String { val id = session.model ?: session.preferredDefaultModel() session.models.firstOrNull { it.value == id }?.let { return modelDisplayLabel(it) } + // A model picked from "Other models" is not in the catalog, so the pill would fall through to + // deriveModelLabel — which is right for `claude-opus-4-7` and wrong for `claude-3-5-sonnet`, where the + // version leads the family and it renders "3 5 Sonnet". The curated label is the authority for ours. + LegacyModels.labelFor(id)?.let { return it } return deriveModelLabel(id) } diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index 7814eb78..aa1853f1 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -296,6 +296,8 @@ // readout (subtle session line) var readout = h('div', { class: 'readout', attrs: { hidden: 'hidden' } }); + // Plan limits, one bar per window, on their own row under the readout — see renderUsageBars(). + var usageBars = h('div', { class: 'usage-bars', attrs: { hidden: 'hidden' } }); var card = h('div', { class: 'composer-card' }, inputRow, bar); @@ -303,6 +305,7 @@ mount.appendChild(queue); mount.appendChild(attachments); mount.appendChild(readout); // session-usage line sits ABOVE the prompt box + mount.appendChild(usageBars); // …and the plan-limit bars directly under it mount.appendChild(card); els = { @@ -313,6 +316,7 @@ queue: queue, ghost: ghost, readout: readout, + usageBars: usageBars, attachments: attachments, attachBtn: attachBtn, }; @@ -518,27 +522,70 @@ if (!opts.length) return; var menu = h('div', { class: 'menu', attrs: { role: 'listbox' } }); + + /** One selectable row. Shared by the main list and the "Other models" group so they cannot drift apart. */ + function optionItem(o) { + var item = h( + 'div', + { + class: 'menu-item' + (o.selected ? ' selected' : ''), + attrs: { role: 'option' }, + on: { + click: function (e) { + e.preventDefault(); + e.stopPropagation(); + chooseOption(def, o); + }, + }, + }, + h('span', { class: 'menu-item-label', text: o.label != null ? String(o.label) : '' }) + ); + // The selected ✓ is drawn by CSS (.menu-item.selected::after) — don't ALSO append a span here, or the + // chosen item shows two ticks. + return item; + } + + // The host tags previous-generation models with group:'other' (JcefState.modelJson). Everything untagged + // stays in the flat list exactly as before, so no other pill's menu changes shape. + var main = []; + var other = []; for (var i = 0; i < opts.length; i++) { - (function (o) { - var item = h( - 'div', - { - class: 'menu-item' + (o.selected ? ' selected' : ''), - attrs: { role: 'option' }, - on: { - click: function (e) { - e.preventDefault(); - e.stopPropagation(); - chooseOption(def, o); - }, + (opts[i].group === 'other' ? other : main).push(opts[i]); + } + for (var j = 0; j < main.length; j++) menu.appendChild(optionItem(main[j])); + + if (other.length) { + // Expanded IN PLACE rather than as a second floating panel: this menu is already position-clamped to a + // narrow tool window, and a flyout would need its own edge handling to avoid opening off-screen. It + // starts open when the current selection lives inside it, so the ✓ is never hidden behind a collapsed row. + var expanded = other.some(function (o) { + return o.selected; + }); + var group = h('div', { class: 'menu-group' + (expanded ? ' open' : '') }); + var items = h('div', { class: 'menu-group-items' }); + var header = h( + 'div', + { + class: 'menu-item menu-group-header', + attrs: { role: 'button', 'aria-expanded': expanded ? 'true' : 'false' }, + on: { + click: function (e) { + e.preventDefault(); + e.stopPropagation(); // never let this reach the document handler that closes the menu + var nowOpen = !group.classList.contains('open'); + group.classList.toggle('open', nowOpen); + header.setAttribute('aria-expanded', nowOpen ? 'true' : 'false'); + if (openMenu && openMenu.anchor) positionMenu(menu, openMenu.anchor); }, }, - h('span', { class: 'menu-item-label', text: o.label != null ? String(o.label) : '' }) - ); - // The selected ✓ is drawn by CSS (.menu-item.selected::after) — don't ALSO append a span here, or the - // chosen item shows two ticks. - menu.appendChild(item); - })(opts[i]); + }, + h('span', { class: 'menu-item-label', text: 'Other models' }), + h('span', { class: 'menu-group-caret' }) + ); + for (var k = 0; k < other.length; k++) items.appendChild(optionItem(other[k])); + group.appendChild(header); + group.appendChild(items); + menu.appendChild(group); } document.body.appendChild(menu); @@ -1024,28 +1071,56 @@ ro.appendChild(h('span', { class: 'ro-item', text: '$' + s.costUsd.toFixed(s.costUsd < 1 ? 4 : 2) })); } - // Plan limits, one dot per window. A dot rather than a bar because this line is glanceable chrome: colour - // carries the urgency and the number carries the detail, and neither needs horizontal room the composer - // does not have. The dashboard shows the same windows as full bars. + // NB no sign-out control here. The readout is a wrapping flex row of metrics, so a button pushed to its + // far end drops onto a second line the moment the numbers fill the width. Log out lives in the tool + // window's title bar (ClaudeToolWindowFactory.SignOutAction) and on the dashboard's account row. + ro.removeAttribute('hidden'); + if (running && s.thinkingStatus) ro.classList.add('thinking'); + else ro.classList.remove('thinking'); + + renderUsageBars(s); + } + + /** + * Plan limits, one labelled bar per window, on their own row directly under the readout. + * + * They used to be dots inline in the readout, which put them at the end of a wrapping row of unrelated + * metrics: the windows that matter most (the ones nearest their cap) were the ones most likely to be pushed + * onto a second line or off the visible width. A bar reads the fill at a glance where a dot only reads a + * colour, and giving them their own row means the length of the status line can no longer displace them. + * + * The row is a `repeat(auto-fit, minmax(…, 1fr))` grid, so it is one bar per column at full tool-window + * width and reflows to fewer, still-full-width columns as the window narrows — never a fixed track that + * leaves dead space on the right or overflows on the left. + */ + function renderUsageBars(s) { + if (!els || !els.usageBars) return; + var host = els.usageBars; + host.innerHTML = ''; var usage = Array.isArray(s.usage) ? s.usage : []; + var shown = 0; for (var u = 0; u < usage.length; u++) { var win = usage[u] || {}; if (typeof win.pct !== 'number') continue; - ro.appendChild( + var label = String(win.label || ''); + var pct = win.pct.toFixed(1) + '%'; + // The BAR is clamped to 0..100 so a server figure past its cap cannot overflow the track; the TEXT is + // not, because a window reported at 103% is exactly the number the user needs to see. + var fill = h('i', { class: usageLevel(win.pct) }); + fill.style.width = Math.max(0, Math.min(100, win.pct)) + '%'; + host.appendChild( h( - 'span', - { class: 'ro-item', title: String(win.label || '') + ' — ' + win.pct.toFixed(1) + '% used' }, - h('span', { class: 'usage-dot ' + usageLevel(win.pct) }), - h('span', { text: String(win.label || '') + ' ' + win.pct.toFixed(1) + '%' }) + 'div', + { class: 'ub-item', title: label + ' — ' + pct + ' used' }, + h('span', { class: 'ub-label', text: label }), + h('span', { class: 'ub-track' }, fill), + h('span', { class: 'ub-pct', text: pct }) ) ); + shown++; } - // NB no sign-out control here. The readout is a wrapping flex row of metrics, so a button pushed to its - // far end drops onto a second line the moment the numbers fill the width. Log out lives in the tool - // window's title bar (ClaudeToolWindowFactory.SignOutAction) and on the dashboard's account row. - ro.removeAttribute('hidden'); - if (running && s.thinkingStatus) ro.classList.add('thinking'); - else ro.classList.remove('thinking'); + if (shown > 0) host.removeAttribute('hidden'); + else host.setAttribute('hidden', 'hidden'); } /** diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index b8b2771d..e9bb5a7e 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -1223,6 +1223,39 @@ mark.cc-hit.active { margin-left: auto; } +/* "Other models": a collapsible group inside a pill menu, expanded in place. + The header is a .menu-item so it inherits hover/focus/padding — it must NOT get the ✓, which is why + .menu-group-header is never given the `selected` class. */ +.menu-group { + border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 4px; +} +.menu-group-header { + color: var(--dim); +} +.menu-group-caret { + margin-left: auto; + font-size: 10px; + color: var(--dim); +} +.menu-group-caret::after { + content: '▸'; +} +.menu-group.open .menu-group-caret::after { + content: '▾'; +} +.menu-group-items { + display: none; +} +.menu-group.open .menu-group-items { + display: block; +} +/* Indented so a collapsed/expanded group reads as a level, not as more of the flat list. */ +.menu-group-items .menu-item { + padding-left: 20px; +} + /* rich attach menu (search + recent files), AI-Assistant style */ .attach-menu { min-width: 290px; @@ -1402,6 +1435,59 @@ mark.cc-hit.active { border-radius: inherit; } +/* ── plan-limit bars, the row under the readout ─────────────────────────────── + auto-fit + 1fr is what makes this responsive without a media query: the row + always spends the FULL tool-window width, dropping to fewer columns as the + window narrows instead of leaving a ragged tail or overflowing. */ +.usage-bars { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 3px 14px; + margin: -4px 4px 8px; + font-size: 12px; + color: var(--dim); +} +.usage-bars .ub-item { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; /* without this a long label refuses to shrink and the grid overflows */ +} +.usage-bars .ub-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.usage-bars .ub-track { + flex: 1 1 40px; + min-width: 24px; + height: 4px; + border-radius: var(--radius-pill); + background: var(--surface2); + overflow: hidden; +} +.usage-bars .ub-track > i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--info); + transition: + width 0.35s var(--ease), + background-color 0.35s var(--ease); +} +.usage-bars .ub-track > i.lvl-mid { + background: var(--warning); +} +.usage-bars .ub-track > i.lvl-high { + background: var(--danger); +} +.usage-bars .ub-pct { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + /* ════════════════════════════════════════════════════════════════════════════ SLASH PALETTE ════════════════════════════════════════════════════════════════════════════ */ @@ -2136,25 +2222,9 @@ mark.cc-hit.active { color: var(--danger); } -/* The composer readout's dot — same scale, glanceable form. */ -.usage-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - margin-right: 6px; - flex: 0 0 auto; - transition: background-color 0.35s var(--ease); -} -.usage-dot.lvl-low { - background: var(--info); -} -.usage-dot.lvl-mid { - background: var(--warning); -} -.usage-dot.lvl-high { - background: var(--danger); -} +/* NB the composer's `.usage-dot` lived here until the plan limits moved to their own bar row under the + readout (`.usage-bars`, further up). Deleted rather than kept warm: an unused rule is a rule nobody + maintains, and the row it styled is not coming back. */ .usage-reset { margin-top: 4px; font-size: 11.5px; diff --git a/src/test/frontend/model-menu.test.js b/src/test/frontend/model-menu.test.js new file mode 100644 index 00000000..95e076db --- /dev/null +++ b/src/test/frontend/model-menu.test.js @@ -0,0 +1,85 @@ +// The model pill's "Other models" group (app-composer.js). +// +// The host tags previous-generation models with `group: 'other'` (JcefState.modelJson) because the binary does +// not offer them at all — they are a curated list. What is pinned here is the rendering contract: tagged options +// are folded away behind one header, untagged ones keep the flat menu they always had, and a selection that +// lives inside the group is never hidden from the user. +const { loadFrontend } = require('./helpers/load'); + +function state(modelOptions) { + return { + turnActive: false, + interrupting: false, + running: true, + provider: { id: 'anthropic', label: 'Anthropic', options: [] }, + model: { label: 'Opus 5', options: modelOptions }, + mode: { wire: 'default', label: 'Default', options: [] }, + effort: { label: 'Default', options: [] }, + thinking: { on: true, options: [] }, + queue: [], + }; +} + +const CURRENT = [ + { value: 'opus[1m]', label: 'Opus 5 with 1M context', selected: true }, + { value: 'sonnet', label: 'Sonnet 5', selected: false }, +]; +const LEGACY = [ + { value: 'claude-opus-4-7', label: 'Opus 4.7', selected: false, group: 'other' }, + { value: 'claude-3-5-sonnet', label: 'Sonnet 3.5', selected: false, group: 'other' }, +]; + +describe('composer — the model menu groups older models', () => { + let win; + + function openModelMenu() { + const pill = + win.document.querySelector('.pill-model') || win.document.querySelector('[data-pill="model"]'); + if (pill) pill.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + return win.document.querySelector('.menu'); + } + + beforeEach(() => { + win = loadFrontend(['app-composer.js']); + }); + + test('current models stay in the flat list and older ones move into the group', () => { + win.cc.state(state(CURRENT.concat(LEGACY))); + const menu = openModelMenu(); + expect(menu).not.toBeNull(); + const group = menu.querySelector('.menu-group'); + expect(group).not.toBeNull(); + expect(group.querySelector('.menu-group-header').textContent).toContain('Other models'); + // The two current models are direct children; the two older ones live inside the group. + expect(menu.querySelectorAll(':scope > .menu-item').length).toBe(CURRENT.length); + expect(group.querySelectorAll('.menu-group-items .menu-item').length).toBe(LEGACY.length); + }); + + test('the group starts collapsed, and open when the selected model is inside it', () => { + win.cc.state(state(CURRENT.concat(LEGACY))); + let menu = openModelMenu(); + expect(menu).not.toBeNull(); + expect(menu.querySelector('.menu-group').classList.contains('open')).toBe(false); + + // Selecting an older model must not hide the ✓ behind a collapsed row. + const selectedLegacy = [ + { value: 'opus[1m]', label: 'Opus 5 with 1M context', selected: false }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', selected: true, group: 'other' }, + ]; + win = loadFrontend(['app-composer.js']); + win.cc.state(state(selectedLegacy)); + menu = openModelMenu(); + expect(menu).not.toBeNull(); + expect(menu.querySelector('.menu-group').classList.contains('open')).toBe(true); + }); + + test('a menu with no tagged options renders exactly as before', () => { + // The regression guard for every OTHER pill (mode, effort, thinking): they send no `group`, so they must + // not grow a group container. + win.cc.state(state(CURRENT)); + const menu = openModelMenu(); + expect(menu).not.toBeNull(); + expect(menu.querySelector('.menu-group')).toBeNull(); + expect(menu.querySelectorAll('.menu-item').length).toBe(CURRENT.length); + }); +}); diff --git a/src/test/frontend/readout.test.js b/src/test/frontend/readout.test.js index 09660962..ebb371f6 100644 --- a/src/test/frontend/readout.test.js +++ b/src/test/frontend/readout.test.js @@ -49,3 +49,71 @@ describe('composer readout', () => { expect(readoutText()).toContain('Running'); }); }); + +// The plan-limit bars: their own row, under the readout. +// +// They were dots inline in the readout, i.e. at the end of a wrapping row of unrelated metrics — so the windows +// nearest their cap were the ones most likely to wrap out of sight. The separation is the point of the row, and +// it is what these pin: the bars are a SIBLING of .readout, not inside it. +describe('plan-limit bars', () => { + let win; + beforeEach(() => { + win = loadFrontend(['app-composer.js'], { vendor: false }); + }); + + const bars = () => win.document.querySelector('.usage-bars'); + const items = () => Array.from(bars().querySelectorAll('.ub-item')); + const base = { running: true, starting: false }; + + it('renders one labelled bar per window, outside the readout', () => { + win.cc.state({ + ...base, + usage: [ + { key: 'five_hour', label: 'Current session', pct: 13 }, + { key: 'seven_day', label: 'All models', pct: 9 }, + { key: 'model_scoped:Fable', label: 'Fable', pct: 71.25 }, + ], + }); + expect(items()).toHaveLength(3); + expect(items().map((el) => el.querySelector('.ub-label').textContent)).toEqual([ + 'Current session', + 'All models', + 'Fable', + ]); + expect(items()[2].querySelector('.ub-pct').textContent).toBe('71.3%'); + // Sibling, not descendant: the readout must be able to grow without displacing them. + expect(win.document.querySelector('.readout .ub-item')).toBeNull(); + }); + + it('sets the fill width from the percentage and its colour from the level', () => { + win.cc.state({ + ...base, + usage: [ + { key: 'a', label: 'Low', pct: 10 }, + { key: 'b', label: 'Mid', pct: 70 }, + { key: 'c', label: 'High', pct: 90 }, + ], + }); + const fills = items().map((el) => el.querySelector('.ub-track > i')); + expect(fills.map((f) => f.style.width)).toEqual(['10%', '70%', '90%']); + expect(fills.map((f) => f.className)).toEqual(['lvl-low', 'lvl-mid', 'lvl-high']); + }); + + it('clamps the BAR past 100% but never the number', () => { + // A window the server reports over its cap is exactly the figure the user needs to read; what must not + // happen is the fill overflowing its track. + win.cc.state({ ...base, usage: [{ key: 'a', label: 'Over', pct: 103 }] }); + expect(items()[0].querySelector('.ub-track > i').style.width).toBe('100%'); + expect(items()[0].querySelector('.ub-pct').textContent).toBe('103.0%'); + }); + + it('hides the row entirely when no window carries a percentage', () => { + win.cc.state({ ...base }); + expect(bars().hasAttribute('hidden')).toBe(true); + win.cc.state({ ...base, usage: [{ key: 'a', label: 'Unknown', pct: null }] }); + expect(bars().hasAttribute('hidden')).toBe(true); + expect(items()).toHaveLength(0); + win.cc.state({ ...base, usage: [{ key: 'a', label: 'Known', pct: 5 }] }); + expect(bars().hasAttribute('hidden')).toBe(false); + }); +}); diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt index 2dccb2c4..3cc15eb6 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt @@ -1,8 +1,13 @@ package dev.lain.claudejb.protocol +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -112,4 +117,335 @@ class UsageReportTest { // An unknown window is still a window the user is limited by — label it rather than hide it. assertEquals("Cowork", RateLimitInfo.windowTitleFor("seven_day_cowork")) } + + @Test + fun `nimbus_quill is dropped, and every other unknown window is still shown`() { + // The server emits a window key that exists in no binary and no SDK type, so nothing can say what it + // meters; it rendered as "Nimbus quill 0.0%". Hidden BY NAME — the general rule (an unknown window is + // still a limit, so label it rather than hide it) must survive intact, or the next real limit + // Anthropic ships disappears silently. + val payload = buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("five_hour") { put("utilization", 4) } + putJsonObject("nimbus_quill") { put("utilization", 0) } + putJsonObject("some_future_window") { put("utilization", 12) } + } + } + val report = requireNotNull(parseUsageReport(payload)) + assertEquals(listOf("five_hour", "some_future_window"), report.windows.map { it.first }) + } + + // --- model_scoped: the per-model weekly windows, the only place "Fable" is ever reported --- + + /** A payload whose `rate_limits` carries the `model_scoped` ARRAY alongside the ordinary window objects. */ + private fun withModelScoped(vararg entries: JsonObject): UsageReport = requireNotNull( + parseUsageReport( + buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("five_hour") { put("utilization", 13) } + putJsonArray("model_scoped") { entries.forEach { add(it) } } + } + }, + ), + ) { "the fixture must parse" } + + private fun modelWindow(name: String?, utilization: Int?): JsonObject = buildJsonObject { + if (name != null) put("display_name", name) + if (utilization != null) put("utilization", utilization) + put("resets_at", "2026-08-14T00:00:00+00:00") + } + + @Test + fun `a model_scoped window is parsed from the array and titled by the server's own name`() { + // THE FEATURE: Fable usage is reported HERE and nowhere else. `model_scoped` is a JSON array, so the + // `value as? JsonObject` walk over `rate_limits` silently dropped every entry — the window did not + // render wrongly, it did not exist. + val report = withModelScoped(modelWindow("Fable", 42)) + val (key, window) = report.windows.single { it.first.startsWith(MODEL_SCOPED_KEY_PREFIX) } + assertEquals("${MODEL_SCOPED_KEY_PREFIX}Fable", key) + assertEquals(42.0, window.utilization) + // Its key is synthetic, so the label can only come from `display_name`; deriving it from the key would + // read "Model scoped:fable". + assertEquals("Fable", window.title(key)) + } + + @Test + fun `a keyed window keeps titling itself from its key`() { + // `title()` is the ONE place every surface labels a window, so it has to keep answering correctly for + // the windows that have no display_name — which is all of them except the model-scoped ones. + assertEquals("Current session", UsageWindow(utilization = 5.0).title("five_hour")) + assertEquals("Opus", UsageWindow(utilization = 5.0).title("seven_day_opus")) + } + + @Test + fun `a model_scoped entry that duplicates a keyed window is dropped`() { + // The SDK calls these additive and the server picks which models qualify, so a "Opus" bucket next to + // the first-class seven_day_opus is expected rather than exceptional. Two bars both reading "Opus" is + // worse than one; the keyed window wins, being the one whose meaning the plugin actually knows. + val payload = buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("seven_day_opus") { put("utilization", 20) } + putJsonArray("model_scoped") { + addJsonObject { + put("display_name", "opus") + put("utilization", 20) + } + addJsonObject { + put("display_name", "Fable") + put("utilization", 7) + } + } + } + } + val report = requireNotNull(parseUsageReport(payload)) + assertEquals(listOf("seven_day_opus", "${MODEL_SCOPED_KEY_PREFIX}Fable"), report.windows.map { it.first }) + } + + @Test + fun `a model_scoped entry with no name or no figure is skipped`() { + // Its key IS its name, so a blank one has neither identity nor title: it would render as an anonymous + // bar, the same unanswerable row nimbus_quill is hidden for. A named entry with no utilization has + // nothing to draw either — and, as everywhere else here, absent is not zero. + val report = withModelScoped( + modelWindow(null, 30), + modelWindow(" ", 30), + modelWindow("Fable", null), + modelWindow("Fable", 55), + ) + assertEquals(listOf("five_hour", "${MODEL_SCOPED_KEY_PREFIX}Fable"), report.windows.map { it.first }) + assertEquals(55.0, report.windows.last().second.utilization) + } + + @Test + fun `model_scoped windows sort after the known ones`() { + // Same rule the unknown top-level keys follow: the windows the plugin understands lead, the additive + // per-model buckets trail, and `sortedBy` is stable so the server's own order is preserved among them. + val report = withModelScoped(modelWindow("Fable", 3), modelWindow("Haiku", 1)) + assertEquals( + listOf("five_hour", "${MODEL_SCOPED_KEY_PREFIX}Fable", "${MODEL_SCOPED_KEY_PREFIX}Haiku"), + report.windows.map { it.first }, + ) + } + + @Test + fun `model_scoped is never itself a window`() { + assertTrue(withModelScoped(modelWindow("Fable", 3)).windows.none { it.first == "model_scoped" }) + } + + @Test + fun `the hidden-window rule is public so both ingestion paths can apply it`() { + // The report path filters in parseUsageReport; the rate_limit_event path filters in + // ClaudeSession.onRateLimit. Filtering only one left "Nimbus quill 0.0%" on screen anyway. + assertTrue(isHiddenUsageWindow("nimbus_quill")) + assertTrue(!isHiddenUsageWindow("five_hour")) + assertTrue(!isHiddenUsageWindow(null)) + } + + // --- the raw `limits[]` array: where Fable ACTUALLY comes from in a plugin session --- + + /** A payload carrying only the raw `rate_limits.limits` array, with no `model_scoped` key at all. */ + private fun withRawLimits(vararg entries: JsonObject): UsageReport = requireNotNull( + parseUsageReport( + buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("five_hour") { put("utilization", 13) } + putJsonArray("limits") { entries.forEach { add(it) } } + } + }, + ), + ) { "the fixture must parse" } + + /** One raw entry, in the binary's own shape: `kind`/`scope.model.display_name`/`percent`/`resets_at`. */ + private fun rawLimit( + model: String?, + percent: Int?, + kind: String = "weekly_scoped", + resetsAt: JsonElement = JsonPrimitive(1_786_000_000L), + ): JsonObject = buildJsonObject { + put("kind", kind) + if (model != null) { + putJsonObject("scope") { putJsonObject("model") { put("display_name", model) } } + } + if (percent != null) put("percent", percent) + put("resets_at", resetsAt) + } + + @Test + fun `a per-model window is projected from the raw limits array when model_scoped is absent`() { + // THE BUG THE USER SAW: the plugin showed "Current session" and "All models" and never Fable, while + // the same account's interactive /usage listed it. `model_scoped` is not a field the server always + // sends — the binary SYNTHESISES it, and only when its `tengu_usage_overage_included_models` gate is + // non-empty (`IUt` returns [] for an empty gate, and the key is spliced in only when the projection + // yields entries). In a --print session it was empty, so the key never arrived. The raw array it + // projects FROM does arrive, and the binary's own /usage formatter reads it off this same payload. + val report = withRawLimits(rawLimit("Fable", 71)) + val (key, window) = report.windows.single { it.first.startsWith(MODEL_SCOPED_KEY_PREFIX) } + assertEquals("${MODEL_SCOPED_KEY_PREFIX}Fable", key) + assertEquals(71.0, window.utilization) + assertEquals("Fable", window.title(key)) + } + + @Test + fun `epoch-seconds resets_at is normalised instead of dropping the window`() { + // The raw entries carry `resets_at` as a NUMBER as often as a string, and `UsageWindow.resetsAt` is a + // String — decoding one straight would fail and take the whole window with it, silently. + val window = withRawLimits(rawLimit("Fable", 5)).windows.single { + it.first.startsWith(MODEL_SCOPED_KEY_PREFIX) + }.second + assertEquals("2026-08-06T07:06:40Z", window.resetsAt) + val iso = "2026-08-14T00:00:00Z" + val asString = withRawLimits(rawLimit("Fable", 5, resetsAt = JsonPrimitive(iso))).windows.single { + it.first.startsWith(MODEL_SCOPED_KEY_PREFIX) + }.second + assertEquals(iso, asString.resetsAt) + } + + @Test + fun `raw entries that are not a per-model weekly window are skipped`() { + // The array holds every limit the account has, most of them the same windows already keyed by name. + // Only `weekly_scoped` entries that actually name a model become a bar; the rest would duplicate a + // window that is already on screen, or be an anonymous one. + val report = withRawLimits( + rawLimit("Fable", 8, kind = "five_hour"), + rawLimit(null, 8), + rawLimit("Nameless", null), + ) + assertTrue(report.windows.none { it.first.startsWith(MODEL_SCOPED_KEY_PREFIX) }) + } + + @Test + fun `a raw entry is dropped when the same model already has a window`() { + // Both dedup rules on one payload: against a keyed window (seven_day_opus vs an "Opus" raw entry) and + // against the binary's own projection, which wins because it IS the server's projection. Otherwise + // enabling the gate would double every bar. + val payload = buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("seven_day_opus") { put("utilization", 20) } + putJsonArray("model_scoped") { add(modelWindow("Fable", 7)) } + putJsonArray("limits") { + add(rawLimit("Opus", 20)) + add(rawLimit("Fable", 99)) + add(rawLimit("Haiku", 4)) + } + } + } + val report = requireNotNull(parseUsageReport(payload)) + assertEquals( + listOf("seven_day_opus", "${MODEL_SCOPED_KEY_PREFIX}Fable", "${MODEL_SCOPED_KEY_PREFIX}Haiku"), + report.windows.map { it.first }, + ) + // The surviving Fable is the projected one (7), not the raw duplicate (99). + assertEquals(7.0, report.windows.first { it.first.endsWith("Fable") }.second.utilization) + } + + @Test + fun `the raw limits array is never itself a window`() { + assertTrue(withRawLimits(rawLimit("Fable", 3)).windows.none { it.first == "limits" }) + } + + // --- mergedOver: a refresh that omits a window is not a claim that the window is gone --- + + @Test + fun `a window missing from the new report is carried forward`() { + // THE BUG THE USER SAW NEXT: the Fable bar blinked out and back every few polls. The binary's usage + // fetch falls back to `seedUtilization()` on a timeout/429/fieldless body, and that object is rebuilt + // from the rate-limit RESPONSE HEADERS — it can only ever carry five_hour and seven_day. Downstream it + // is flagged "seeded" and then treated exactly like a full reply, so the omission is invisible. + val previous = withRawLimits(rawLimit("Fable", 71)) + val seeded = requireNotNull( + parseUsageReport( + buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("five_hour") { put("utilization", 20) } + putJsonObject("seven_day") { put("utilization", 9) } + } + }, + ), + ) + val merged = seeded.mergedOver(previous) + assertEquals( + listOf("five_hour", "seven_day", "${MODEL_SCOPED_KEY_PREFIX}Fable"), + merged.windows.map { it.first }, + ) + // The fresh reading wins where the reply HAS one; only the untold window keeps its last value. + assertEquals(20.0, merged.windows.first { it.first == "five_hour" }.second.utilization) + assertEquals(71.0, merged.windows.first { it.first.endsWith("Fable") }.second.utilization) + } + + @Test + fun `a window the new report does mention is never overwritten by the old one`() { + val previous = withRawLimits(rawLimit("Fable", 71)) + val fresh = withRawLimits(rawLimit("Fable", 4)) + assertEquals( + 4.0, + fresh.mergedOver(previous).windows.first { it.first.endsWith("Fable") }.second.utilization, + ) + assertEquals(previous.windows.size, fresh.mergedOver(previous).windows.size) + } + + @Test + fun `merging over nothing is the report itself`() { + val report = withRawLimits(rawLimit("Fable", 71)) + assertEquals(report, report.mergedOver(null)) + assertEquals(report, report.mergedOver(UsageReport())) + } + + @Test + fun `the extra-credit balance is not carried forward`() { + // Unlike a window, `extra` being null already means "this plan has no extra-credit balance" as often as + // it means "this reply did not say" — carrying it would keep a balance on screen after it is turned off. + val previous = requireNotNull( + parseUsageReport( + buildJsonObject { + put("rate_limits_available", true) + putJsonObject("rate_limits") { + putJsonObject("five_hour") { put("utilization", 3) } + putJsonObject("extra_usage") { + put("is_enabled", true) + put("used_credits", 12) + } + } + }, + ), + ) + assertNotNull(previous.extra) + assertNull(withRawLimits(rawLimit("Fable", 5)).mergedOver(previous).extra) + } + + // --- UsageWindow.utilizationPercent: the scale, and the bug that came from guessing it --- + + @Test + fun `a window at one percent is one percent, not a hundred`() { + // THE BUG, as a test. `ClaudeSession` held a private copy of a "the wire sends both 0..100 and 0..1, + // accept either" heuristic: any value <= 1.0 was multiplied by 100. So a window at a genuine 1% came + // out as 100%, crossed the 85% threshold and raised an IDE notification saying the plan was spent — + // at the moment the user had spent almost none of it, i.e. right after a window reset. + assertEquals(1, UsageWindow(utilization = 1.0).utilizationPercent()) + assertEquals(1, UsageWindow(utilization = 0.9).utilizationPercent()) + assertEquals(0, UsageWindow(utilization = 0.0).utilizationPercent()) + } + + @Test + fun `the get_usage scale is a percentage and is passed through`() { + // sdk.d.ts on every window: "Percentage of the window used, 0-100". The live fixture above says the + // same thing out loud — it carries 8 and 67. + assertEquals(8, UsageWindow(utilization = 8.0).utilizationPercent()) + assertEquals(92, UsageWindow(utilization = 92.0).utilizationPercent()) + assertEquals(100, UsageWindow(utilization = 100.0).utilizationPercent()) + } + + @Test + fun `an absent utilization stays absent, and nonsense is clamped`() { + // null must not become 0: "unknown" and "none used" are different claims, and only one of them is + // safe to skip a quota warning on. The clamp is belt-and-braces against a wire value out of range. + assertNull(UsageWindow().utilizationPercent()) + assertEquals(100, UsageWindow(utilization = 250.0).utilizationPercent()) + assertEquals(0, UsageWindow(utilization = -5.0).utilizationPercent()) + } } diff --git a/src/test/kotlin/dev/lain/claudejb/session/LegacyModelsTest.kt b/src/test/kotlin/dev/lain/claudejb/session/LegacyModelsTest.kt new file mode 100644 index 00000000..8dab980b --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/LegacyModelsTest.kt @@ -0,0 +1,85 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * [LegacyModels] — the curated "Other models" list. + * + * The list itself is data, so what is worth pinning is the two rules that keep it honest: it must never + * duplicate a model the binary already offers, and it must never smuggle in a CURRENT model, which is what + * would turn an append-only historical list back into something that goes stale. + */ +class LegacyModelsTest { + + /** The catalog the binary really returns (claude 2.1.223), verified via the `initialize` control request. */ + private val liveCatalog = listOf("default", "opus[1m]", "claude-fable-5[1m]", "sonnet", "haiku") + + @Test + fun `no entry collides with the current catalog`() { + val offered = LegacyModels.offeredAlongside(liveCatalog).map { it.value } + assertEquals(LegacyModels.ALL.size, offered.size, "nothing in the live catalog should be filtered out") + liveCatalog.forEach { current -> + assertFalse(current in offered, "$current is offered by the binary and must not be duplicated here") + } + } + + @Test + fun `a catalog that starts listing an older id wins, and the entry drops out`() { + // The graceful path for the day the binary decides to offer one of these itself: it appears once, from + // the catalog, with the catalog's own label — not twice. + val offered = LegacyModels.offeredAlongside(liveCatalog + "claude-opus-4-5").map { it.value } + assertFalse("claude-opus-4-5" in offered) + assertTrue("claude-opus-4-1" in offered, "the rest of the list is unaffected") + } + + @Test + fun `every entry is a previous generation, never the current one`() { + // The distinction this whole file rests on: historical ids are immutable facts and can be listed; + // naming the CURRENT tier is what went stale in 4.3.3 ("Default · Opus 4.8") and is banned here. + val current = listOf("opus-5", "fable-5", "sonnet-5", "haiku-4-5") + LegacyModels.ALL.forEach { entry -> + current.forEach { tier -> + assertFalse(entry.value.contains(tier), "${entry.value} names a current model") + } + } + } + + @Test + fun `labels are curated because deriving them is wrong for the 3-x naming scheme`() { + // `claude-3-5-sonnet` puts the version BEFORE the family, which the generic deriver renders "3 5 Sonnet". + assertEquals("Sonnet 3.5", LegacyModels.labelFor("claude-3-5-sonnet")) + assertEquals("Opus 4.7", LegacyModels.labelFor("claude-opus-4-7")) + assertNull(LegacyModels.labelFor("opus[1m]"), "a current model is not ours to label") + assertNull(LegacyModels.labelFor(null)) + } + + @Test + fun `every id is one the binary actually knows, not one that fits the numbering pattern`() { + // The guard for the mistake this list invited: `claude-opus-4-2` and `claude-sonnet-4-2` read as + // perfectly plausible — the numbering has 4.0, 4.1, 4.5, 4.6, 4.7 — and neither has ever existed. An + // invented id is refused at `set_model`, i.e. it looks like a broken menu entry, not like a typo. + // + // Fixture below = every model id in the model tables of `claude` 2.1.223, extracted with the grep in + // LegacyModels' KDoc (date-suffixed and `-latest`/`-fast`/`-vN` variants folded away). Refresh it from + // the binary — never by hand — when adding an entry. + val knownToBinary = setOf( + "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", + "claude-opus-4-1", "claude-opus-4-0", "claude-opus-4", + "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4-0", "claude-sonnet-4", + "claude-3-7-sonnet", "claude-3-5-sonnet", + "claude-haiku-4-5", "claude-haiku-4", "claude-3-5-haiku", + ) + LegacyModels.ALL.forEach { entry -> + assertTrue(entry.value in knownToBinary, "${entry.value} is in no model table of the shipped binary") + } + } + + @Test + fun `ids are unique`() { + assertEquals(LegacyModels.ALL.size, LegacyModels.ALL.map { it.value }.toSet().size) + } +}