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) + } +}