diff --git a/src/web/public/app.js b/src/web/public/app.js
index d256f436..c80d526c 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -2634,8 +2634,19 @@ class CodemanApp {
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
- const seg = (label, p) => {
- if (p === null) return '';
+ // `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
+ // dropping it. Claude only: Claude Code documents `five_hour` as "present
+ // only while the API reports it and its resets_at has not passed", so that
+ // key leaves the statusline payload whenever no 5-hour session window is
+ // open, and a chip that silently shrank from two windows to one read as a
+ // broken feature rather than as an idle window (reported 2026-09-01). A
+ // missing CODEX bucket means the opposite — that plan has no such limit —
+ // so those stay omitted rather than showing a dash forever.
+ const seg = (label, p, idle) => {
+ if (p === null) {
+ if (!idle) return '';
+ return `${label}—`;
+ }
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `${label}${n}%`;
@@ -2644,24 +2655,31 @@ class CodemanApp {
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
- const row = (provider, usage) => {
- const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
+ const row = (provider, usage, idle) => {
+ // hasWindows() gates the row, so a placeholder can only ever appear
+ // ALONGSIDE a real reading — a provider reporting nothing still renders
+ // nothing, never a row of em dashes.
+ if (!hasWindows(usage)) return '';
+ const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `${provider}` : '';
return `${label}${windows.join('·')}`;
};
- const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
+ const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
- const details = (provider, usage) => {
+ const details = (provider, usage, idle) => {
const lines = [];
const five = pct(usage?.fiveHour);
const seven = pct(usage?.sevenDay);
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
+ else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
- chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
+ chip.title =
+ [details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
+ 'Plan usage limits';
}
// Scheduled runs
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index a517b3c0..f382677d 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -12470,6 +12470,13 @@ kbd {
color: var(--text-dim);
opacity: 0.45;
}
+/* A window Claude is not currently reporting: the slot stays, dimmed, so the
+ chip keeps its shape instead of looking like half of it broke. */
+.header-plan-usage .pu-win-idle .pu-label,
+.header-plan-usage .pu-win-idle .pu-val {
+ color: var(--text-dim);
+ opacity: 0.55;
+}
/* Green/yellow/red by how much of the window is used up. */
.header-plan-usage .pu-green {
color: #3fb950;
diff --git a/test/plan-usage-chip.test.ts b/test/plan-usage-chip.test.ts
index 4a801eed..6cd6a46e 100644
--- a/test/plan-usage-chip.test.ts
+++ b/test/plan-usage-chip.test.ts
@@ -80,6 +80,34 @@ describe('header plan usage chip', () => {
expect(codexRow).toContain('7d');
});
+ it("keeps Claude's 5h slot as a dash when no session window is open", () => {
+ // Claude Code ships `five_hour` "only while the API reports it and its
+ // resets_at has not passed", so between session windows the key is simply
+ // absent. The chip used to shrink to a lone 7d segment, which reads as a
+ // broken feature rather than an idle window (reported 2026-09-01).
+ const { CodemanApp, chip } = loadCodemanAppClass();
+ const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
+
+ app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } });
+
+ expect(chip.innerHTML).toContain('pu-win-idle');
+ expect(chip.innerHTML).toContain('5h');
+ expect(chip.innerHTML).toContain('52%');
+ expect(chip.title).toContain('no active session window');
+ });
+
+ it('renders no row at all for a provider reporting nothing', () => {
+ // The placeholder must never stand alone: a row of em dashes would claim a
+ // provider is idle when it is really absent.
+ const { CodemanApp, chip } = loadCodemanAppClass();
+ const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
+
+ app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } });
+
+ expect(chip.innerHTML).not.toContain('pu-win-idle');
+ expect(chip.innerHTML).toContain('40%');
+ });
+
it('drops the provider label when Claude is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;