+
+
{labelPct}
+ {canSwitch || winLabel !== '·' ? (
+
+ ) : null}
+ {shortReset ? (
+
{shortReset}
+ ) : null}
+ {/* Worst pace across all windows — after reset date */}
+
+
+ );
+});
+
+PlanUsageIndicator.displayName = 'PlanUsageIndicator';
diff --git a/webview/src/components/ChatInputBox/styles/context-bar.css b/webview/src/components/ChatInputBox/styles/context-bar.css
index 43fb8043..aedc1a5a 100644
--- a/webview/src/components/ChatInputBox/styles/context-bar.css
+++ b/webview/src/components/ChatInputBox/styles/context-bar.css
@@ -365,3 +365,131 @@ button.context-file-placeholder:focus-visible {
}
}
+/* Plan usage indicator (layout D: mini-bar + % + window + short reset) */
+.plan-usage {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ max-width: 200px;
+ min-width: 0;
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1;
+ flex-shrink: 1;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+/* Leading dot = worst pace across all windows (not just selected). */
+.plan-usage-worst-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--text-secondary);
+ opacity: 0.85;
+}
+.plan-usage-worst-dot.pace-green {
+ background: #3ba55d;
+ opacity: 1;
+}
+.plan-usage-worst-dot.pace-yellow {
+ background: #d4a017;
+ opacity: 1;
+}
+.plan-usage-worst-dot.pace-red {
+ background: #e34850;
+ opacity: 1;
+}
+.plan-usage-worst-dot.pace-neutral {
+ background: var(--text-secondary);
+ opacity: 0.5;
+}
+
+.plan-usage-bar {
+ width: 36px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--input-border);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.plan-usage-fill {
+ height: 100%;
+ border-radius: 2px;
+ background: var(--text-secondary);
+ transition: width 0.25s ease, background 0.2s ease;
+}
+
+.plan-usage-pct {
+ font-variant-numeric: tabular-nums;
+ font-weight: 500;
+ flex-shrink: 0;
+}
+
+.plan-usage-reset {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--text-secondary);
+ opacity: 0.9;
+}
+
+/* 5h/7d window switcher */
+.plan-usage-window {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 1px 4px;
+ border: 1px solid var(--input-border);
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: 0.02em;
+ text-transform: lowercase;
+ cursor: default;
+}
+.plan-usage-window.switchable {
+ cursor: pointer;
+}
+.plan-usage-window.switchable:hover {
+ background: var(--button-hover);
+ color: var(--text-primary);
+}
+.plan-usage-window:disabled {
+ opacity: 0.85;
+}
+
+.plan-usage.pace-green .plan-usage-fill {
+ background: #3ba55d;
+}
+.plan-usage.pace-green .plan-usage-pct {
+ color: #3ba55d;
+}
+
+.plan-usage.pace-yellow .plan-usage-fill {
+ background: #d4a017;
+}
+.plan-usage.pace-yellow .plan-usage-pct {
+ color: #d4a017;
+}
+
+.plan-usage.pace-red .plan-usage-fill {
+ background: #e34850;
+}
+.plan-usage.pace-red .plan-usage-pct {
+ color: #e34850;
+}
+
+.plan-usage.unavailable {
+ opacity: 0.75;
+}
+
+.plan-usage-label {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
diff --git a/webview/src/utils/planUsagePace.test.ts b/webview/src/utils/planUsagePace.test.ts
new file mode 100644
index 00000000..7e557bf3
--- /dev/null
+++ b/webview/src/utils/planUsagePace.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import {
+ formatShortReset,
+ nextWindowId,
+ paceColor,
+ parseCapacityPayload,
+ resolveDisplayWindow,
+ windowShortLabel,
+ worstPaceColor,
+} from './planUsagePace';
+
+const claudePayload = {
+ ok: true,
+ present: true,
+ provider: 'claude',
+ source: 'sdk-rate-limit',
+ capacity_pct: 92,
+ reset_at: '2026-08-23T03:00:00Z',
+ period_type: '5h',
+ windows: [
+ { id: '5h', used_pct: 92, reset_at: '2026-08-23T03:00:00Z', period_type: '5h' },
+ { id: '7d', used_pct: 21, reset_at: '2026-08-25T00:00:00Z', period_type: '7d' },
+ ],
+};
+
+describe('parseCapacityPayload', () => {
+ it('normalizes snake_case windows into a snapshot', () => {
+ const snap = parseCapacityPayload(claudePayload);
+ expect(snap.present).toBe(true);
+ expect(snap.capacityPct).toBe(92);
+ expect(snap.provider).toBe('claude');
+ expect(snap.windows?.map((w) => w.id)).toEqual(['5h', '7d']);
+ expect(snap.windows?.[1].usedPct).toBe(21);
+ });
+
+ it('marks unavailable payloads not present', () => {
+ const snap = parseCapacityPayload({ present: false, unavailable: true, message: 'no data' });
+ expect(snap.present).toBe(false);
+ expect(snap.message).toBe('no data');
+ });
+
+ it('falls back to the first window when top-level pct is missing', () => {
+ const snap = parseCapacityPayload({
+ windows: [{ id: '5h', used_pct: 42, reset_at: '2026-08-23T03:00:00Z' }],
+ });
+ expect(snap.present).toBe(true);
+ expect(snap.capacityPct).toBe(42);
+ expect(snap.periodType).toBeNull();
+ });
+});
+
+describe('paceColor', () => {
+ it('green below budget, yellow within +5, red past it, neutral without budget', () => {
+ expect(paceColor(30, 50)).toBe('green');
+ expect(paceColor(52, 50)).toBe('yellow');
+ expect(paceColor(56, 50)).toBe('red');
+ expect(paceColor(30, null)).toBe('neutral');
+ });
+});
+
+describe('worstPaceColor', () => {
+ it('takes the worst window, not the selected one', () => {
+ // now chosen so 5h is far past its time budget (red) while 7d is far under (green)
+ const now = new Date('2026-08-22T22:30:00Z');
+ const snap = parseCapacityPayload(claudePayload);
+ expect(worstPaceColor(snap, now)).toBe('red');
+ });
+});
+
+describe('window switching', () => {
+ const snap = parseCapacityPayload(claudePayload);
+ const windows = snap.windows ?? [];
+
+ it('cycles 5h → 7d → 5h', () => {
+ expect(nextWindowId(windows, '5h')).toBe('7d');
+ expect(nextWindowId(windows, '7d')).toBe('5h');
+ });
+
+ it('prefers the stored window id and falls back to top-level binding', () => {
+ expect(resolveDisplayWindow(snap, '7d')).toMatchObject({ windowId: '7d', capacityPct: 21 });
+ expect(resolveDisplayWindow(snap, null).capacityPct).toBe(92);
+ });
+
+ it('labels raw ids compactly', () => {
+ expect(windowShortLabel('5h')).toBe('5h');
+ expect(windowShortLabel('7d')).toBe('7d');
+ expect(windowShortLabel('WEEKLY')).toBe('7d');
+ expect(windowShortLabel(null)).toBe('·');
+ });
+});
+
+describe('formatShortReset', () => {
+ it('emits day + short month + 24h time with no trailing period', () => {
+ const label = formatShortReset('2026-08-23T03:00:00Z', 'en-GB');
+ expect(label).not.toMatch(/\.$/);
+ // timezone-agnostic structure: "23 Aug 03:00" (locale day/time vary by TZ)
+ expect(label.trim()).toMatch(/^\d{1,2} [A-Za-z]{3,4} \d{2}:\d{2}$/);
+ });
+
+ it('returns empty string for missing dates', () => {
+ expect(formatShortReset(null)).toBe('');
+ });
+});
diff --git a/webview/src/utils/planUsagePace.ts b/webview/src/utils/planUsagePace.ts
new file mode 100644
index 00000000..92cfbe52
--- /dev/null
+++ b/webview/src/utils/planUsagePace.ts
@@ -0,0 +1,359 @@
+/**
+ * Plan-usage pace coloring for the ContextBar usage bar.
+ * TP = actual usage %, TT = linear time budget through the reset window.
+ */
+
+export type PaceColor = 'green' | 'yellow' | 'red' | 'neutral';
+
+/** One rate/billing window from a capacity payload windows[]. */
+export interface CapacityWindow {
+ id: string;
+ usedPct: number;
+ resetAt?: string | null;
+ periodType?: string | null;
+}
+
+export interface PlanUsageSnapshot {
+ present: boolean;
+ /** Usage percent 0–100 (TP) — binding/worst-case at top level. */
+ capacityPct?: number;
+ /** Period end / next reset (ISO or parseable date string). */
+ resetAt?: string | null;
+ /** Period start when known. */
+ periodStart?: string | null;
+ /** 5h | 7d | … */
+ periodType?: string | null;
+ /** All known windows (e.g. 5h/7d). */
+ windows?: CapacityWindow[];
+ /** Provider from capacity payload (claude | …). */
+ provider?: string;
+ source?: string;
+ message?: string;
+}
+
+export const PLAN_USAGE_WINDOW_STORAGE_KEY = 'ccgui.planUsage.windowId';
+
+const YELLOW_BAND = 5;
+
+/** Clamp number to [0, 100]. */
+export function clampPercent(n: number): number {
+ if (!Number.isFinite(n)) return 0;
+ return Math.max(0, Math.min(100, n));
+}
+
+/**
+ * Derive period start from end + period_type when start is missing.
+ * 5H → end − 5h; 7D/WEEKLY → end − 7d; MONTHLY → end − 30d.
+ */
+export function derivePeriodStart(
+ resetAt: Date,
+ periodType?: string | null,
+): Date | null {
+ // Accept raw enums too, e.g. USAGE_PERIOD_TYPE_WEEKLY from xAI billing.
+ const t = (periodType || '').toUpperCase();
+ if (t === '5H' || t === '5HR' || t.includes('5H')) {
+ return new Date(resetAt.getTime() - 5 * 60 * 60 * 1000);
+ }
+ if (t === '7D' || t === '7DAY' || (t.includes('7D') && !t.includes('WEEK'))) {
+ return new Date(resetAt.getTime() - 7 * 24 * 60 * 60 * 1000);
+ }
+ if (t === 'WEEKLY' || t === 'WEEK' || t.includes('WEEK')) {
+ return new Date(resetAt.getTime() - 7 * 24 * 60 * 60 * 1000);
+ }
+ if (t === 'MONTHLY' || t === 'MONTH' || t.includes('MONTH')) {
+ return new Date(resetAt.getTime() - 30 * 24 * 60 * 60 * 1000);
+ }
+ return null;
+}
+
+export function parseDate(value?: string | null): Date | null {
+ if (!value || typeof value !== 'string') return null;
+ const d = new Date(value);
+ return Number.isFinite(d.getTime()) ? d : null;
+}
+
+/**
+ * TT = linear time progress through [start, end], 0–100.
+ * Returns null when window cannot be determined.
+ */
+export function computeTimeBudgetPercent(
+ now: Date,
+ periodStart?: Date | null,
+ periodEnd?: Date | null,
+): number | null {
+ if (!periodStart || !periodEnd) return null;
+ const start = periodStart.getTime();
+ const end = periodEnd.getTime();
+ if (!(end > start)) return null;
+ const t = now.getTime();
+ return clampPercent(((t - start) / (end - start)) * 100);
+}
+
+/**
+ * Resolve TT from snapshot fields (explicit start or period_type).
+ */
+export function resolveTimeBudget(
+ snapshot: Pick