Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ plugins {
}

group = "dev.lain"
version = "5.1.0"
version = "5.1.1"

repositories {
mavenCentral()
Expand Down
13 changes: 13 additions & 0 deletions docs/RELEASE_PROCEDURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
32 changes: 31 additions & 1 deletion src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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%.
*
Expand Down Expand Up @@ -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 }

Expand Down
25 changes: 16 additions & 9 deletions src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
22 changes: 15 additions & 7 deletions src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/main/resources/jcef/app-composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
28 changes: 28 additions & 0 deletions src/main/resources/jcef/app-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
// ---------------------------------------------------------------------------
Expand Down
10 changes: 2 additions & 8 deletions src/main/resources/jcef/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading