diff --git a/CHANGELOG.md b/CHANGELOG.md index c64b57a4..5309f760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ 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.1] — 2026-08-10 + +### Fixed +- **The plan limits stopped refreshing whenever the panel was not on screen.** The poll was gated on + `isShowing`, so a collapsed tool window or a chat tab that was not the selected one asked for nothing at + all — and a quota window is not the plugin's state to begin with: other sessions, other devices and + claude.ai spend the same windows, and a **reset is a wall-clock event that owes nothing to this IDE**. The + figure on screen was therefore whatever the last probe happened to catch, and it only moved again when + something else triggered one — a turn, or opening the dashboard. "It only updates when I talk to the agent" + is precisely what a visibility-gated poll looks like from outside. The gate is gone and the period is 30 s; + what it was saving is one control request per half minute against a process that is already running, and + the event-driven refreshes (turn edges, `rate_limit_event`, dashboard open, session ready) are unchanged. + +### Added +- **The chat's plan-limit row now says how long each window has left** — `Reset time: 4h 18m` on its own line + directly under that window's bar, with the full sentence in the tooltip. A percentage alone does not say + whether it is urgent: 90% with eight minutes to go and 90% with six hours to go are different situations, + and only the dashboard was answering that. Under the bar rather than beside it because the row is already + three items wide per window, and a fourth made the countdown the first thing to be squeezed out — the one + case where it matters most. The countdown is computed by one function in `app-core` + (`CC.resetIn`/`resetInShort`) that the dashboard card now shares, and a window with no reset time renders + no element rather than an empty slot that would read as "resets now". + +### Changed +- **Every `get_usage` poll now logs the reply it got**, `rate_limits` verbatim (truncated), at INFO. The + derived per-window lines cannot answer the question that keeps coming up — *is the number on screen stale, + or is the server still saying that?* — because a window the reply omits leaves no line at all, and one + carried forward from the previous poll is indistinguishable from a fresh one. It immediately earned its + place: a live capture showed **two of three consecutive polls** coming back in the header-seeded shape + (`five_hour`/`seven_day` only, no `limits[]`, `resets_at` rounded to `.000Z`), which is the degraded reply + 5.1.0's merge exists for, and confirmed the binary does not cache the endpoint. +- `RateLimitInfo.resetsAtIso()` puts the epoch-seconds → ISO-8601 conversion on the model, so a window that + reaches a surface from the *event* stream and one that arrives in the `get_usage` *report* are + interchangeable to everything that renders them. It was a private copy in the dashboard's builder, and the + composer needed the same thing. + ## [5.1.0] — 2026-08-10 ### Added diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a6995677..b820968c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +## v5.1.1 — 2026-08-10 + +**The plan limits kept updating only when you talked to the agent.** The poll stopped whenever the chat was +not on screen — a collapsed tool window, or another tab selected — so a limit could reset, or fill up from +another device, and the bars went on showing the last figure they happened to catch until something made you +send a message. They now refresh every 30 seconds regardless of what you are looking at. + +**And the bars say how long each window has left** — `Reset time: 4h 18m`, right under each one. 90% with +eight minutes to go and 90% with six hours to go are not the same situation, and until now only the +dashboard told you which one you were in. + ## v5.1.0 — 2026-08-10 **Older models are selectable again.** The model picker has an **Other models** group with the previous diff --git a/build.gradle.kts b/build.gradle.kts index 883128c3..0401e086 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.1.0" +version = "5.1.1" repositories { mavenCentral() diff --git a/docs/RELEASE_PROCEDURE.md b/docs/RELEASE_PROCEDURE.md index 39760250..61b8159f 100644 --- a/docs/RELEASE_PROCEDURE.md +++ b/docs/RELEASE_PROCEDURE.md @@ -145,6 +145,19 @@ Merge once the GitHub Actions CI workflow is green. **Do not** rebase onto `main` — use a merge commit so the tag points to a commit that exists on both branches. +**The release PR is closed to late commits, cosmetic ones above all.** Everything the release +contains — code, tests, changelog, release notes, the version bump, and any wording or formatting +touch-up — goes in *before* the PR is opened. Once it is open, the branch is frozen except for a +fix to something the review or CI actually found. + +This is not tidiness. A commit pushed onto an open release PR invalidates every gate that already +passed on it: the full local battery was run against a tree that is no longer the tree being +merged, and `main` is a publishing branch, so what lands there reaches users without a further +approval step. A "cosmetic" commit is the worst version of this, because it is the one nobody +re-verifies — the diff looks harmless, so the checks get read as still valid when they belong to a +different commit. If something cosmetic turns up mid-PR, it waits for the next release; if it truly +cannot wait, close the PR, land the change, re-run the full battery, and open a new one. + ### 7. Tag and push After the PR is merged: diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt index f535dfaa..5d75f4cf 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt @@ -291,6 +291,13 @@ data class RateLimitInfo( fun utilizationPercent(): Int? = utilization?.let { Math.round(it * PERCENT).toInt().coerceIn(0, 100) } + /** + * [resetsAt] as ISO-8601, the shape `get_usage` already uses, so a window sourced from an EVENT and one + * sourced from the REPORT are interchangeable to every surface that renders a countdown. The conversion + * lives on the model because both UI builders need it and two copies would be two chances to drift. + */ + fun resetsAtIso(): String? = resetsAt?.let { java.time.Instant.ofEpochSecond(it).toString() } + val isWarning: Boolean get() = status == "allowed_warning" || status == "rejected" val isExhausted: Boolean get() = status == "rejected" diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 1524dc1b..70229c8f 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -45,6 +45,7 @@ import dev.lain.claudejb.settings.SecretStore import dev.lain.claudejb.ui.ClaudeSettingsConfigurable import dev.lain.claudejb.ui.ReviewPrompt import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull @@ -1669,10 +1670,36 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : onResult(report) } }, - decode = ::parseUsageReport, + decode = { payload -> + logUsageReply(payload) + parseUsageReport(payload) + }, ) } + /** + * Logs what the `get_usage` poll actually came back with, once per poll, at INFO. + * + * The derived per-window lines below cannot answer the question that keeps coming up — *is the figure on + * screen stale, or is the server really still saying that?* — because a window the reply omits leaves no + * line at all, and a carried-forward one is indistinguishable from a fresh one. This prints the wire: + * `rate_limits` verbatim, truncated. It is what told us the binary was not caching and that the replies + * during a two-hour exhausted window were complete rather than the header-seeded fallback. + * + * One line every 30 s is the deliberate cost. It is bounded (the payload is a handful of windows) and the + * alternative is a user reporting a wrong number with nothing in `idea.log` to check it against. + */ + private fun logUsageReply(payload: JsonObject?) { + val limits = payload?.get("rate_limits") + if (limits == null || limits is JsonNull) { + // NOT the same as an empty object: the binary sends null when plan limits do not apply at all + // (API key, Bedrock, Vertex) or when its own fetch had nothing to fall back on. + log.info("get_usage: rate_limits=null (available=${payload?.get("rate_limits_available")})") + return + } + log.info("get_usage: ${limits.toString().take(USAGE_LOG_CHARS)}") + } + /** * Announces the first time a quota window crosses 65% and again at 85%. * @@ -2648,6 +2675,9 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : private val QUOTA_THRESHOLDS = listOf(65, 85) private const val QUOTA_THRESHOLD_HIGH = 85 + /** Truncation for the `get_usage` reply trace — a bound, since the payload is not ours to size. */ + private const val USAGE_LOG_CHARS = 2000 + /** Transports JetBrains' MCP server exposes; stdio is synthesized from the running IDE. */ val IDE_MCP_TRANSPORTS = McpTransport.entries.map { it.wire } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index 23d77199..068f5b07 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -93,13 +93,20 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : private var lastUsageAt = 0L /** - * Plan-limits poll. Unlike context and cost — which cannot move while the session idles, so their timer - * retires at turn end — the quota IS shared state: other sessions, other devices and claude.ai itself - * consume the same windows, and a window reset is a wall-clock event. So this ticks for the panel's whole - * lifetime, gated on [isShowing]: a background tab skips the round-trip and catches up within one tick of - * being brought forward. + * Plan-limits poll, unconditional for the panel's whole lifetime. + * + * Unlike context and cost — which cannot move while the session idles, so their timer retires at turn end + * — the quota IS shared state: other sessions, other devices and claude.ai itself consume the same + * windows, and **a window reset is a wall-clock event that owes nothing to this IDE**. + * + * It used to be gated on [isShowing], and that gate is the bug: a tool window the user had collapsed, or + * a chat tab that was not the selected one, stopped asking entirely — so a window could reset, or fill + * from another device, and the panel went on displaying the last figure it happened to catch until + * something else (a turn, opening the dashboard) triggered a probe. "It only updates when I talk to the + * agent" is exactly what a visibility-gated poll looks like from outside. The round-trip it saved is one + * control request every half minute against a process that is already running. */ - private val usageTimer = Timer(USAGE_POLL_MS) { if (isShowing) requestUsage() }.apply { isRepeats = true } + private val usageTimer = Timer(USAGE_POLL_MS) { requestUsage() }.apply { isRepeats = true } /** Last observed process liveness, so [onStateChanged] can spot a restart. EDT-confined. */ private var wasRunning = false @@ -917,13 +924,13 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** How many recently-opened files the attach menu offers before the user has to search. */ private const val RECENT_FILES_LIMIT = 14 - /** Period of the plan-limits poll while the panel is visible. */ - private const val USAGE_POLL_MS = 15_000 + /** Period of the plan-limits poll, visible or not — a window reset happens on wall-clock time. */ + private const val USAGE_POLL_MS = 30_000 /** * Floor between `get_usage` round-trips — burst protection for the event-driven triggers. MUST stay * below [USAGE_POLL_MS], or the periodic tick is silently throttled away and the poll only *looks* - * like it runs every 15 s. + * like it runs on its period. */ private const val USAGE_MIN_INTERVAL_MS = 12_000L 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 95c0836f..432fec8d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -93,7 +93,7 @@ object JcefSessionData { .filterKeys { key -> fromReport.none { it.key == key } } .map { (key, info) -> val pct = info.utilization?.let { it * 100 } - Window(key, RateLimitInfo.windowTitleFor(key), pct, info.resetsAt?.let(::isoOf), info.isExhausted) + Window(key, RateLimitInfo.windowTitleFor(key), pct, info.resetsAtIso(), info.isExhausted) } val windows = fromReport + fromEvents if (windows.isEmpty() && report?.extra == null) return null @@ -135,10 +135,6 @@ object JcefSessionData { put("limitReached", extra.spendLimitReached) } - /** Epoch seconds → ISO-8601, so event-sourced windows match the shape `get_usage` already returns. */ - private fun isoOf(epochSeconds: Long): String = - java.time.Instant.ofEpochSecond(epochSeconds).toString() - private const val TEN = 10.0 private fun Double.pow(exp: Int): Double = Math.pow(this, exp.toDouble()) 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 8170401c..cb464af1 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -35,22 +35,30 @@ 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 { Triple(key, w.title(key), it) } + w.utilization?.let { CompactWindow(key, w.title(key), it, w.resetsAt) } } val fromEvents = session.rateLimits - .filterKeys { key -> fromReport.none { it.first == key } } + .filterKeys { key -> fromReport.none { it.key == key } } .mapNotNull { (key, info) -> - info.utilization?.let { Triple(key, RateLimitInfo.windowTitleFor(key), it * 100) } + info.utilization?.let { + CompactWindow(key, RateLimitInfo.windowTitleFor(key), it * 100, info.resetsAtIso()) + } } - (fromReport + fromEvents).forEach { (key, label, pct) -> + (fromReport + fromEvents).forEach { w -> addJsonObject { - put("key", key) - put("label", label) - put("pct", pct) + put("key", w.key) + put("label", w.label) + put("pct", w.pct) + // The countdown, so the readout can say how long the window has left. A percentage alone does + // not tell you whether 90% is urgent; only the dashboard was answering that. + w.resetsAt?.let { put("resetsAt", it) } } } } + /** One window as the composer readout needs it; a Triple stopped being readable at four fields. */ + private data class CompactWindow(val key: String, val label: String, val pct: Double, val resetsAt: String?) + fun stateJson(session: ClaudeSession, usage: UsageReport? = null): String { val provider = session.provider val mode = session.permissionMode diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index aa1853f1..85509ae1 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -1108,15 +1108,28 @@ // 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( + // How long the window has left, on its OWN line under the bar. A percentage without it says how much is + // spent but not whether that matters: 90% with eight minutes to go and 90% with six hours to go are + // different situations, and only the dashboard was answering that. Under the bar rather than beside it + // because the bar row is already three items wide per window — a fourth turned the countdown into the + // first thing to be squeezed out, which is the one case where it matters most. + var reset = CC.resetInShort(win.resetsAt); + var item = h( + 'div', + { + class: 'ub-item', + title: label + ' — ' + pct + ' used' + (reset ? ' · ' + CC.resetIn(win.resetsAt) : ''), + }, h( 'div', - { class: 'ub-item', title: label + ' — ' + pct + ' used' }, + { class: 'ub-row' }, h('span', { class: 'ub-label', text: label }), h('span', { class: 'ub-track' }, fill), h('span', { class: 'ub-pct', text: pct }) ) ); + if (reset) item.appendChild(h('span', { class: 'ub-reset', text: 'Reset time: ' + reset })); + host.appendChild(item); shown++; } if (shown > 0) host.removeAttribute('hidden'); diff --git a/src/main/resources/jcef/app-core.js b/src/main/resources/jcef/app-core.js index ed024657..79350f59 100644 --- a/src/main/resources/jcef/app-core.js +++ b/src/main/resources/jcef/app-core.js @@ -314,6 +314,34 @@ return EXT_LANG[ext] || null; }; + // --------------------------------------------------------------------------- + // How long a quota window has left. Relative, because an absolute timestamp + // makes the reader do the arithmetic. Lives here rather than in one of the + // two modules that render it, so the dashboard card and the composer's bar + // row can never disagree about what "shortly" means. + // --------------------------------------------------------------------------- + /** Minutes until `iso`, or null when it is missing or unparseable. */ + function minutesUntil(iso) { + if (!iso) return null; + var when = Date.parse(iso); + if (isNaN(when)) return null; + return Math.round((when - Date.now()) / 60000); + } + /** "4h 50m" / "12m" / "soon" — the compact form, for the composer's bar row. */ + CC.resetInShort = function (iso) { + var mins = minutesUntil(iso); + if (mins === null) return null; + if (mins <= 0) return 'soon'; + var hours = Math.floor(mins / 60); + return hours > 0 ? hours + 'h ' + (mins % 60) + 'm' : mins + 'm'; + }; + /** "Resets in 4h 50m" — the sentence form, for the dashboard card and tooltips. */ + CC.resetIn = function (iso) { + var short = CC.resetInShort(iso); + if (short === null) return null; + return short === 'soon' ? 'Resets shortly' : 'Resets in ' + short; + }; + // --------------------------------------------------------------------------- // Tiny event bus: on(event, fn) / emit(event, ...args). // --------------------------------------------------------------------------- diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js index eca50f2e..20ba4bf8 100644 --- a/src/main/resources/jcef/app-session.js +++ b/src/main/resources/jcef/app-session.js @@ -176,15 +176,9 @@ return 'lvl-low'; } - /** "Resets in 4h 50m" — relative, because an absolute timestamp makes you do the arithmetic yourself. */ + /** "Resets in 4h 50m" — one implementation, in app-core, shared with the composer's bar row. */ function resetIn(iso) { - if (!iso) return null; - var when = Date.parse(iso); - if (isNaN(when)) return null; - var mins = Math.round((when - Date.now()) / 60000); - if (mins <= 0) return 'Resets shortly'; - var hours = Math.floor(mins / 60); - return 'Resets in ' + (hours > 0 ? hours + 'h ' + (mins % 60) + 'm' : mins + 'm'); + return CC.resetIn(iso); } function buildContextCard(ctx) { diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index e9bb5a7e..c0fb8620 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -1441,17 +1441,24 @@ mark.cc-hit.active { window narrows instead of leaving a ragged tail or overflowing. */ .usage-bars { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 3px 14px; margin: -4px 4px 8px; font-size: 12px; color: var(--dim); } +/* Two lines per window: the bar row, and the countdown under it. */ .usage-bars .ub-item { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; /* without this a long label refuses to shrink and the grid overflows */ +} +.usage-bars .ub-row { display: flex; align-items: center; gap: 6px; - min-width: 0; /* without this a long label refuses to shrink and the grid overflows */ + min-width: 0; } .usage-bars .ub-label { flex: 0 1 auto; @@ -1487,6 +1494,17 @@ mark.cc-hit.active { flex: 0 0 auto; font-variant-numeric: tabular-nums; } +/* Time left in the window, on its own line under the bar. Dimmer and smaller on + purpose: it qualifies the percentage, it does not compete with it. Never wraps + — a countdown broken across two lines reads as two values. */ +.usage-bars .ub-reset { + font-size: 11px; + opacity: 0.72; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} /* ════════════════════════════════════════════════════════════════════════════ SLASH PALETTE diff --git a/src/test/frontend/readout.test.js b/src/test/frontend/readout.test.js index ebb371f6..dcf7d0f5 100644 --- a/src/test/frontend/readout.test.js +++ b/src/test/frontend/readout.test.js @@ -107,6 +107,34 @@ describe('plan-limit bars', () => { expect(items()[0].querySelector('.ub-pct').textContent).toBe('103.0%'); }); + it('shows how long each window has left, compactly, with the sentence in the tooltip', () => { + // A percentage alone does not say whether it is urgent: 90% with eight minutes to go and 90% with six + // hours to go are different situations, and only the dashboard was answering that. + const in90min = new Date(Date.now() + 90 * 60000).toISOString(); + win.cc.state({ + ...base, + usage: [ + { key: 'five_hour', label: 'Current session', pct: 90, resetsAt: in90min }, + { key: 'seven_day', label: 'All models', pct: 9 }, + ], + }); + expect(items()[0].querySelector('.ub-reset').textContent).toBe('Reset time: 1h 30m'); + // Its own line under the bar row, not a fourth item squeezed into it. + expect(items()[0].querySelector('.ub-row .ub-reset')).toBeNull(); + expect(items()[0].querySelector('.ub-row .ub-pct')).not.toBeNull(); + expect(items()[0].getAttribute('title')).toContain('Resets in 1h 30m'); + // A window with no reset time gets no element at all — an empty slot would read as "resets now". + expect(items()[1].querySelector('.ub-reset')).toBeNull(); + expect(items()[1].getAttribute('title')).not.toContain('Resets'); + }); + + it('says "soon" once the reset time has passed rather than a negative countdown', () => { + const past = new Date(Date.now() - 60000).toISOString(); + win.cc.state({ ...base, usage: [{ key: 'a', label: 'Over', pct: 100, resetsAt: past }] }); + expect(items()[0].querySelector('.ub-reset').textContent).toBe('Reset time: soon'); + expect(items()[0].getAttribute('title')).toContain('Resets shortly'); + }); + it('hides the row entirely when no window carries a percentage', () => { win.cc.state({ ...base }); expect(bars().hasAttribute('hidden')).toBe(true); diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt index 3cc15eb6..04d67cf3 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt @@ -418,6 +418,15 @@ class UsageReportTest { assertNull(withRawLimits(rawLimit("Fable", 5)).mergedOver(previous).extra) } + @Test + fun `an event-sourced window converts its reset time to the shape the report already uses`() { + // The composer readout and the dashboard both render a countdown, and a window can reach them from + // either path. One conversion on the model, so an event-sourced window and a report-sourced one are + // interchangeable to every surface instead of ISO in one place and epoch seconds in the other. + assertEquals("2026-08-13T16:59:59Z", RateLimitInfo(resetsAt = 1_786_640_399L).resetsAtIso()) + assertNull(RateLimitInfo().resetsAtIso()) + } + // --- UsageWindow.utilizationPercent: the scale, and the bug that came from guessing it --- @Test