diff --git a/README.md b/README.md index e191d65..76fc41c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ![Tooltip](assets/tooltip.png) - Token context usage -- Session limits +- Session limits (the 5-hour limit, hidden once Anthropic removes it from a verified Ultra account) - Weekly limits - Limit consumption and reset times - Claude service status (working, partial outage, major outage) diff --git a/extension.js b/extension.js index 25450cc..a493c70 100644 --- a/extension.js +++ b/extension.js @@ -1002,7 +1002,8 @@ function registerCommands(context) { // the name is PII (see README Privacy - "redacted bug reports"). const ai = usageData.accountInfo; debugChannel.appendLine(` Account: ${ai ? `present (${ai.organizationType || ai.orgType || 'plan unknown'})` : 'unknown'}`); - debugChannel.appendLine(` Session Usage: ${usageData.usagePercent}%`); + const sessionUsage = usageData.usagePercent == null ? 'no 5-hour limit' : `${usageData.usagePercent}%`; + debugChannel.appendLine(` Session Usage: ${sessionUsage}`); debugChannel.appendLine(` Weekly Usage: ${usageData.usagePercentWeek}%`); debugChannel.appendLine(` Has Monthly Credits: ${!!usageData.monthlyCredits}`); } else { diff --git a/src/activityMonitor.js b/src/activityMonitor.js index 99d29d0..b6bfd02 100644 --- a/src/activityMonitor.js +++ b/src/activityMonitor.js @@ -16,7 +16,7 @@ function pickRandom(messages) { } function getActivityLevel(usageData = null, sessionData = null) { - const claudePercent = usageData ? usageData.usagePercent : 0; + const claudePercent = usageData?.usagePercent ?? 0; let tokenPercent = 0; if (sessionData && sessionData.tokenUsage) { @@ -52,7 +52,7 @@ function getActivityDescription(level) { } function getStats(usageData = null, sessionData = null) { - const claudePercent = usageData ? usageData.usagePercent : 0; + const claudePercent = usageData?.usagePercent ?? 0; let tokenPercent = 0; if (sessionData && sessionData.tokenUsage) { diff --git a/src/apiSchema.js b/src/apiSchema.js index 84190ca..50e8510 100644 --- a/src/apiSchema.js +++ b/src/apiSchema.js @@ -214,7 +214,8 @@ function processApiResponse(apiResponse, creditsData, overageData, accountInfo, const { session, weekly } = resetGauges(gauges); return { - usagePercent: session.percent ?? 0, + // Null when the account has no 5-hour limit (verified Ultra accounts). + usagePercent: session.percent ?? null, // Rendered against the clock at display time, so cached data never drifts. resetsAt: session.resetsAt || null, usagePercentWeek: weekly.percent ?? 0, diff --git a/src/commands/simulator.js b/src/commands/simulator.js index d95e9ca..4af5338 100644 --- a/src/commands/simulator.js +++ b/src/commands/simulator.js @@ -107,11 +107,17 @@ function registerSimulatorCommands(context, performFetch) { simulator.setTokenUsed(v === '' ? null : Number(v)); await performFetch(false); }), - percentCommand({ - id: 'claudemeter.simulate.sessionPercent', - prompt: 'Force session % (0-100, blank to clear)', - setter: simulator.setSessionPercent, - performFetch, + vscode.commands.registerCommand('claudemeter.simulate.sessionPercent', async () => { + const v = await vscode.window.showInputBox({ + prompt: 'Force session % (0-100, "none" for no 5-hour limit, blank to clear)', + validateInput: (s) => s.trim().toLowerCase() === 'none' ? null : percentValidator(s), + }); + if (v === undefined) return; + const choice = v.trim().toLowerCase(); + if (choice === '') simulator.setSessionPercent(null); + else if (choice === 'none') simulator.setSessionPercent('none'); + else simulator.setSessionPercent(Number(choice)); + await performFetch(false); }), percentCommand({ id: 'claudemeter.simulate.weeklyPercent', diff --git a/src/credentialsReader.js b/src/credentialsReader.js index 83a0ded..83fc7bf 100644 --- a/src/credentialsReader.js +++ b/src/credentialsReader.js @@ -100,9 +100,16 @@ function formatSubscriptionType(type) { return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(); } +// Anthropic renamed these plans but kept the tier strings: Max 20x is Ultra, +// Max 5x is Max. +const RENAMED_TIERS = { + default_claude_max_20x: 'Ultra', + default_claude_max_5x: 'Max', +}; + function formatRateLimitTier(tier) { if (!tier) return null; - // "default_claude_max_20x" -> "Max 20x" + if (Object.hasOwn(RENAMED_TIERS, tier)) return RENAMED_TIERS[tier]; // "default_claude_pro" -> "Pro" const match = tier.match(/default_claude_(\w+?)(?:_(\d+x))?$/); if (match) { diff --git a/src/simulator.js b/src/simulator.js index e03caf7..4bc6d10 100644 --- a/src/simulator.js +++ b/src/simulator.js @@ -13,6 +13,7 @@ const VALID_COLOR_MODES = new Set(['color', 'basic']); let simulatedTokenLevel = null; let simulatedTokenUsed = null; // raw token count, lets simulator drive 'used' let simulatedSessionPercent = null; +let simulatedNoSessionLimit = false; // the payload a verified Ultra account gets let simulatedWeeklyPercent = null; let simulatedScopedWeekly = null; // [{label, percent}], overrides usageData.scopedWeekly let simulatedThresholdIcons = null; // null | true | false, stands in for the global setting @@ -48,13 +49,16 @@ function setTokenUsed(used) { } function getTokenUsed() { return simulatedTokenUsed; } +// 'none' simulates an account with no 5-hour limit. function setSessionPercent(p) { - if (p === null) { simulatedSessionPercent = null; return; } + simulatedNoSessionLimit = p === 'none'; + if (p === null || simulatedNoSessionLimit) { simulatedSessionPercent = null; return; } const v = clamp01_100(p); if (v === null) return; simulatedSessionPercent = v; } function getSessionPercent() { return simulatedSessionPercent; } +function isNoSessionLimit() { return simulatedNoSessionLimit; } function setWeeklyPercent(p) { if (p === null) { simulatedWeeklyPercent = null; return; } @@ -143,6 +147,7 @@ function clearAll() { simulatedTokenLevel = null; simulatedTokenUsed = null; simulatedSessionPercent = null; + simulatedNoSessionLimit = false; simulatedWeeklyPercent = null; simulatedScopedWeekly = null; simulatedThresholdIcons = null; @@ -157,7 +162,7 @@ function clearAll() { module.exports = { setTokenLevel, getTokenLevel, setTokenUsed, getTokenUsed, - setSessionPercent, getSessionPercent, + setSessionPercent, getSessionPercent, isNoSessionLimit, setWeeklyPercent, getWeeklyPercent, setScopedWeekly, getScopedWeekly, setThresholdIcons, getThresholdIcons, diff --git a/src/statusBar.js b/src/statusBar.js index 310ba60..23cfde6 100644 --- a/src/statusBar.js +++ b/src/statusBar.js @@ -921,7 +921,10 @@ function createStatusBarItem(context) { return statusBarItems.label; } -function updateStatusBar(item, usageData, activityStats = null, sessionData = null, credentialsInfo = null) { +function updateStatusBar(item, usageDataIn, activityStats = null, sessionData = null, credentialsInfo = null) { + const usageData = usageDataIn && simulator.isNoSessionLimit() + ? { ...usageDataIn, usagePercent: null, resetsAt: null } + : usageDataIn; // Happy-hour panel is independent of fetch state - render on // every tick so the countdown stays fresh. const happyHourState = resolveHappyHourState(); diff --git a/src/tooltipComposer.js b/src/tooltipComposer.js index c38febb..c3f467f 100644 --- a/src/tooltipComposer.js +++ b/src/tooltipComposer.js @@ -130,9 +130,11 @@ function renderPlanAndContext(state) { const lines = []; if (credentialsInfo) { - const plan = formatSubscriptionType(credentialsInfo.subscriptionType); const tier = formatRateLimitTier(credentialsInfo.rateLimitTier); const orgType = ORG_TYPE_LABELS[usageData?.accountInfo?.organizationType]; + // On a personal plan the 'max' token covers both Max and Ultra, so the tier names it. + const tierNamesPlan = credentialsInfo.subscriptionType === 'max' && tier && !orgType; + const plan = tierNamesPlan ? tier : formatSubscriptionType(credentialsInfo.subscriptionType); const orgName = usageData?.accountInfo?.orgName; let planLabel = plan && esc(plan); @@ -186,9 +188,12 @@ function renderSessionBlock(state) { const lines = ['']; // leading blank line for visual separation const sessionPercent = usageData.usagePercent; - const resetTimeExpanded = calculateResetClockTimeExpanded(usageData.resetsAt); - lines.push(`**Session - ${sessionPercent}%**`); - lines.push(`Resets ${resetTimeExpanded}`); + if (typeof sessionPercent === 'number') { + lines.push(`**Session - ${sessionPercent}%**`); + lines.push(`Resets ${calculateResetClockTimeExpanded(usageData.resetsAt)}`); + } else { + lines.push('**Session - no 5-hour limit**'); + } if (config?.tokenLimitOverride > 0) { lines.push(`⚙ Context window override ${formatCompact(config.tokenLimitOverride)}`); } diff --git a/tests/unit/apiSchema.test.js b/tests/unit/apiSchema.test.js index fb7fd28..4dd83f0 100644 --- a/tests/unit/apiSchema.test.js +++ b/tests/unit/apiSchema.test.js @@ -186,6 +186,22 @@ describe('processApiResponse', () => { expect(result.rawData).toBe(apiData); }); + // A verified Ultra account has no 5-hour limit: no five_hour block, no session entry. + it.each([ + ['five_hour null', { five_hour: null }], + ['five_hour absent', {}], + ['utilization null', { five_hour: { utilization: null, resets_at: null } }], + ])('reports no session percent when %s', (_label, sessionShape) => { + const result = processApiResponse({ + ...sessionShape, + seven_day: { utilization: 34, resets_at: '2026-11-06T19:00:00Z' }, + limits: [{ kind: 'weekly_all', group: 'weekly', percent: 34, resets_at: '2026-11-06T19:00:00Z', scope: null }], + }, null, null, null); + expect(result.usagePercent).toBeNull(); + expect(result.resetsAt).toBeNull(); + expect(result.usagePercentWeek).toBe(34); + }); + // A duration taken at fetch time goes stale in the cache; the stamp does not. it('stores the reset stamps as the payload gave them', () => { const result = processApiResponse({ diff --git a/tests/unit/credentialsReader.test.js b/tests/unit/credentialsReader.test.js index a63d837..b1dcb22 100644 --- a/tests/unit/credentialsReader.test.js +++ b/tests/unit/credentialsReader.test.js @@ -29,12 +29,11 @@ describe('formatSubscriptionType', () => { }); describe('formatRateLimitTier', () => { - it('formats max 20x tier', () => { - expect(formatRateLimitTier('default_claude_max_20x')).toBe('Max 20x'); - }); - - it('formats max 5x tier', () => { - expect(formatRateLimitTier('default_claude_max_5x')).toBe('Max 5x'); + it.each([ + ['default_claude_max_20x', 'Ultra'], + ['default_claude_max_5x', 'Max'], + ])('shows the renamed plan for %s', (tier, label) => { + expect(formatRateLimitTier(tier)).toBe(label); }); it('formats pro tier', () => { diff --git a/tests/unit/extensionLifecycle.test.js b/tests/unit/extensionLifecycle.test.js index e987d58..43d5c5c 100644 --- a/tests/unit/extensionLifecycle.test.js +++ b/tests/unit/extensionLifecycle.test.js @@ -310,11 +310,12 @@ describe('identity file re-read', () => { writeCache('acct-a', usage(11, { name: 'A', rateLimitTier: 'default_claude_max_5x' })); await h.activate(); await h.runCommand('claudemeter.fetchNow'); - expect(tooltip()).toContain('Max 5x'); + expect(tooltip()).toContain('Max'); + expect(tooltip()).not.toContain('Ultra'); writeIdentity({ accountUuid: 'acct-a', organizationUuid: 'org-1', organizationType: 'claude_max' }); await h.fireIdentityChange('.claude.json'); - expect(tooltip()).toContain('Max 5x'); - expect(tooltip()).not.toContain('Max 20x'); + expect(tooltip()).toContain('Max'); + expect(tooltip()).not.toContain('Ultra'); }); it('does not copy /profile identity into the credentials record', async () => { diff --git a/tests/unit/statusBarRender.test.js b/tests/unit/statusBarRender.test.js index 248913a..1943679 100644 --- a/tests/unit/statusBarRender.test.js +++ b/tests/unit/statusBarRender.test.js @@ -449,3 +449,42 @@ describe('Tk profile detection from the render path', () => { expect(warnings()).toHaveLength(0); }); }); + +describe('an account with no 5-hour limit', () => { + const simulator = require('../../src/simulator'); + afterEach(() => simulator.clearAll()); + + const noSessionPayload = () => processApiResponse({ + five_hour: null, + seven_day: { utilization: 34, resets_at: new Date(Date.now() + 3 * 86400_000).toISOString() }, + limits: [{ kind: 'weekly_all', group: 'weekly', percent: 34, resets_at: null, scope: null }], + }, null, null, null); + + it.each([ + ['payload', 'default', () => noSessionPayload()], + ['payload', 'compact', () => noSessionPayload()], + ['simulator', 'default', () => { simulator.setSessionPercent('none'); return usage(); }], + ['simulator', 'compact', () => { simulator.setSessionPercent('none'); return usage(); }], + ])('%s, %s mode: draws Wk, no Se, and says so in the tooltip', (_source, mode, makeUsage) => { + const panels = freshPanels({ 'statusBar.displayMode': mode }); + updateStatusBar(null, makeUsage(), null, SESSION, null); + if (mode === 'compact') { + expect(panels.compact.text).toContain('Wk'); + expect(panels.compact.text).not.toMatch(/(^|\s)S\d/); + } else { + expect(panels.session.visible).toBe(false); + expect(panels.weekly.visible).toBe(true); + } + const tooltip = panels.all.map(tip).find((t) => t.includes('**Session')); + expect(tooltip).toContain('**Session - no 5-hour limit**'); + }); + + it('a number after "none" brings the session gauge back', () => { + const panels = freshPanels(); + simulator.setSessionPercent('none'); + simulator.setSessionPercent(55); + updateStatusBar(null, usage(), null, SESSION, null); + expect(panels.session.visible).toBe(true); + expect(panels.session.text).toMatch(/^Se /); + }); +}); diff --git a/tests/unit/tooltipComposer.test.js b/tests/unit/tooltipComposer.test.js index 7a7c594..829fbae 100644 --- a/tests/unit/tooltipComposer.test.js +++ b/tests/unit/tooltipComposer.test.js @@ -126,12 +126,16 @@ describe('composeTooltip - outside strings cannot inject a command link', () => }); describe('composeTooltip - plan + context', () => { - it('shows plan with Personal suffix when the plan token names no shared org', () => { + it.each([ + ['default_claude_max_5x', 'Max'], + ['default_claude_max_20x', 'Ultra'], + ])('names a personal %s plan once, with the Personal suffix', (tier, label) => { const out = composeTooltip({ config: baseConfig, - credentialsInfo: { subscriptionType: 'max', rateLimitTier: 'default_claude_max_5x' }, + credentialsInfo: { subscriptionType: 'max', rateLimitTier: tier }, }); - expect(out).toMatch(/Max \(Personal\)/); + expect(out).toContain(`${label} (Personal)`); + expect(out).not.toMatch(/Max 20x|Max 5x|·/); }); it.each([ @@ -192,6 +196,15 @@ describe('composeTooltip - session block', () => { expect(out).toContain(`Resets ${expected}`); }); + it.each([null, undefined])('says there is no 5-hour limit when the percent is %s', (usagePercent) => { + const out = composeTooltip({ + config: baseConfig, + usageData: { usagePercent, resetsAt: null, timestamp: new Date() }, + }); + expect(out).toContain('**Session - no 5-hour limit**'); + expect(out).not.toMatch(/Session - (null|undefined)|Resets/); + }); + it('shows "-" rather than the current time when the stamp is missing', () => { const out = composeTooltip({ config: baseConfig,