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;