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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/activityMonitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/apiSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions src/commands/simulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 8 additions & 1 deletion src/credentialsReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions src/simulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -143,6 +147,7 @@ function clearAll() {
simulatedTokenLevel = null;
simulatedTokenUsed = null;
simulatedSessionPercent = null;
simulatedNoSessionLimit = false;
simulatedWeeklyPercent = null;
simulatedScopedWeekly = null;
simulatedThresholdIcons = null;
Expand All @@ -157,7 +162,7 @@ function clearAll() {
module.exports = {
setTokenLevel, getTokenLevel,
setTokenUsed, getTokenUsed,
setSessionPercent, getSessionPercent,
setSessionPercent, getSessionPercent, isNoSessionLimit,
setWeeklyPercent, getWeeklyPercent,
setScopedWeekly, getScopedWeekly,
setThresholdIcons, getThresholdIcons,
Expand Down
5 changes: 4 additions & 1 deletion src/statusBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 9 additions & 4 deletions src/tooltipComposer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)}`);
}
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/apiSchema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 5 additions & 6 deletions tests/unit/credentialsReader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions tests/unit/extensionLifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/statusBarRender.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 /);
});
});
19 changes: 16 additions & 3 deletions tests/unit/tooltipComposer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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,
Expand Down
Loading