diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89d29b2..ee4097d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -128,6 +128,14 @@ idle だが、エラー扱い(`failed`)にはせず「制限が解けるの `error === 'rate_limit'`、および usage-limit を示す `result`/throw されたエラー文言(`isRateLimitError`。 SDK の `USAGE_LIMIT_ERROR_PREFIXES` に追従)。制限は一時的なので保存時は `interrupted` に丸める。 +`rate_limit_event` は `rejected` でセッションを `rate_limited` にする一方、`allowed` / `allowed_warning` +も含めて **アカウント全体の claude.ai サブスクリプション使用状況**(5時間枠・週次枠など)を運んでくる。 +これはセッション状態ではなくアカウント横断の情報なので、`Session` は `onRateLimit`(DI)で生の +`rate_limit_info` を `SessionManager` へ渡し、manager が **ウィンドウ種別ごとに最新値**を保持する +(`core/rate-limit.ts` の `toRateLimitWindow` で正規化 = `resetsAt` は秒→ms、`utilization` は 0-100%)。 +`getRateLimits()` は表示順にソートした安定参照を返し、`Banner` が `useRateLimit` で購読して +「現在のセッション 5% 使用 ・ 4時間45分後にリセット」のように描画する(枠が無い=API キー利用時は非表示)。 + `SessionState`(UI が購読する不変スナップショット): ```typescript diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 231cb82..ebb3f89 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -127,6 +127,28 @@ export interface Messages { model: (name: string) => string; /** model 未設定時に表示するプレースホルダ(CLI 既定)。 */ defaultModel: string; + /** + * claude.ai サブスクリプションの使用リミット表示(SDK の rate_limit_event 由来)。 + * ウィンドウ見出しのキーは core の RateLimitLabelKey と一致させる。 + */ + usage: { + /** セクション先頭のラベル(「使用状況」)。 */ + heading: string; + /** 5時間枠(現在のセッション)の見出し。 */ + session: string; + /** 週次枠の見出し。 */ + week: string; + /** 週次枠(Opus 専用)の見出し。 */ + weekOpus: string; + /** 週次枠(Sonnet 専用)の見出し。 */ + weekSonnet: string; + /** 追加利用(overage)枠の見出し。 */ + overage: string; + /** 使用率(0-100 の整数パーセント)。 */ + used: (pct: number) => string; + /** リセットまでの残り時間(日・時・分)。 */ + resetsIn: (days: number, hours: number, minutes: number) => string; + }; }; /** 下部モード行(status-footer.tsx) */ footer: { @@ -250,6 +272,24 @@ const ja: Messages = { subtitle: '並列 Claude Code セッションを git worktree 上で実行', model: (name) => `モデル: ${name}`, defaultModel: 'CLI 既定', + usage: { + heading: '使用状況', + session: '現在のセッション', + week: '今週', + weekOpus: '今週 (Opus)', + weekSonnet: '今週 (Sonnet)', + overage: '追加利用', + used: (pct) => `${pct}% 使用`, + resetsIn: (days, hours, minutes) => { + const when = + days > 0 + ? `${days}日${hours}時間` + : hours > 0 + ? `${hours}時間${minutes}分` + : `${minutes}分`; + return `${when}後にリセット`; + }, + }, }, footer: { autoMode: '自動モード', @@ -363,6 +403,20 @@ const en: Messages = { subtitle: 'Parallel Claude Code sessions in git worktrees', model: (name) => `model: ${name}`, defaultModel: 'CLI default', + usage: { + heading: 'Usage', + session: 'Current session', + week: 'This week', + weekOpus: 'This week (Opus)', + weekSonnet: 'This week (Sonnet)', + overage: 'Overage', + used: (pct) => `${pct}% used`, + resetsIn: (days, hours, minutes) => { + const when = + days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + return `resets in ${when}`; + }, + }, }, footer: { autoMode: 'auto mode on', diff --git a/src/core/index.ts b/src/core/index.ts index be34c11..9ab8bfa 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ export * from './mouse'; export * from './notify'; export * from './persistence'; export * from './pr-coordinator'; +export * from './rate-limit'; export * from './run-mode'; export * from './scroll'; export * from './sdk-parse'; diff --git a/src/core/rate-limit.spec.ts b/src/core/rate-limit.spec.ts new file mode 100644 index 0000000..f45f7c2 --- /dev/null +++ b/src/core/rate-limit.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { + type RateLimitWindow, + rateLimitLabelKey, + resetCountdown, + sameRateLimitWindow, + sortRateLimitWindows, + toRateLimitWindow, +} from './rate-limit'; + +describe('toRateLimitWindow', () => { + it('parses a real SDK rate_limit_info payload (resetsAt seconds → ms)', () => { + // Shape taken verbatim from src/core/__fixtures__/session-basic.jsonl (real SDK output). + const window = toRateLimitWindow({ + status: 'allowed_warning', + resetsAt: 1785542400, + rateLimitType: 'overage', + utilization: 3.49, + }); + expect(window).toEqual({ + type: 'overage', + status: 'allowed_warning', + utilization: 3.49, + resetsAt: 1785542400_000, + }); + }); + + it('parses a five_hour window', () => { + expect( + toRateLimitWindow({ + status: 'allowed', + resetsAt: 1785542400, + rateLimitType: 'five_hour', + utilization: 5, + }), + ).toEqual({ type: 'five_hour', status: 'allowed', utilization: 5, resetsAt: 1785542400_000 }); + }); + + it('passes through a value that is already epoch ms', () => { + const window = toRateLimitWindow({ + status: 'allowed', + resetsAt: 1785542400_000, + rateLimitType: 'five_hour', + }); + expect(window?.resetsAt).toBe(1785542400_000); + }); + + it.each([ + ['missing info', undefined], + ['unknown type', { status: 'allowed', rateLimitType: 'monthly' }], + ['missing type', { status: 'allowed' }], + ['unknown status', { status: 'throttled', rateLimitType: 'five_hour' }], + ['missing status', { rateLimitType: 'five_hour' }], + ])('returns undefined for %s', (_label, info) => { + expect(toRateLimitWindow(info)).toBeUndefined(); + }); + + it('drops an unusable utilization but keeps the window', () => { + const window = toRateLimitWindow({ + status: 'allowed', + rateLimitType: 'five_hour', + utilization: -1, + }); + expect(window?.utilization).toBeUndefined(); + }); + + it('drops an unusable resetsAt but keeps the window', () => { + const window = toRateLimitWindow({ + status: 'allowed', + rateLimitType: 'five_hour', + resetsAt: 0, + }); + expect(window?.resetsAt).toBeUndefined(); + }); +}); + +describe('rateLimitLabelKey', () => { + it.each([ + ['five_hour', 'session'], + ['seven_day', 'week'], + ['seven_day_overage_included', 'week'], + ['seven_day_sonnet', 'weekSonnet'], + ['seven_day_opus', 'weekOpus'], + ['overage', 'overage'], + ] as const)('maps %s → %s', (type, key) => { + expect(rateLimitLabelKey(type)).toBe(key); + }); +}); + +describe('sortRateLimitWindows', () => { + it('orders five_hour first and overage last regardless of input order', () => { + const w = (type: RateLimitWindow['type']): RateLimitWindow => ({ type, status: 'allowed' }); + const sorted = sortRateLimitWindows([w('overage'), w('seven_day'), w('five_hour')]); + expect(sorted.map((x) => x.type)).toEqual(['five_hour', 'seven_day', 'overage']); + }); + + it('collapses windows that share a display label, keeping the higher-priority type', () => { + const w = (type: RateLimitWindow['type']): RateLimitWindow => ({ type, status: 'allowed' }); + // seven_day and seven_day_overage_included both label as "week" → only one row. + const sorted = sortRateLimitWindows([w('seven_day_overage_included'), w('seven_day')]); + expect(sorted.map((x) => x.type)).toEqual(['seven_day']); + }); +}); + +describe('sameRateLimitWindow', () => { + const base: RateLimitWindow = { + type: 'five_hour', + status: 'allowed', + utilization: 5, + resetsAt: 1000, + }; + it('is true for identical windows', () => { + expect(sameRateLimitWindow(base, { ...base })).toBe(true); + }); + it('is false when any field differs', () => { + expect(sameRateLimitWindow(base, { ...base, utilization: 6 })).toBe(false); + expect(sameRateLimitWindow(base, { ...base, status: 'rejected' })).toBe(false); + expect(sameRateLimitWindow(base, { ...base, resetsAt: 2000 })).toBe(false); + }); +}); + +describe('resetCountdown', () => { + const MIN = 60_000; + it('splits remaining time into d/h/m', () => { + const now = 0; + const resetsAt = (4 * 60 + 45) * MIN; // 4h45m + expect(resetCountdown(resetsAt, now)).toEqual({ days: 0, hours: 4, minutes: 45 }); + }); + it('handles multi-day windows', () => { + const now = 0; + const resetsAt = (6 * 1440 + 3 * 60 + 12) * MIN; // 6d3h12m + expect(resetCountdown(resetsAt, now)).toEqual({ days: 6, hours: 3, minutes: 12 }); + }); + it('clamps a past reset to zero', () => { + expect(resetCountdown(0, 10 * MIN)).toEqual({ days: 0, hours: 0, minutes: 0 }); + }); +}); diff --git a/src/core/rate-limit.ts b/src/core/rate-limit.ts new file mode 100644 index 0000000..212f576 --- /dev/null +++ b/src/core/rate-limit.ts @@ -0,0 +1,176 @@ +/** + * claude.ai subscription usage limits, as reported by the SDK's `rate_limit_event` + * (see `SDKRateLimitInfo`). Pure domain: parsing, normalization, and display + * selectors live here so `sdk-parse` / `session-manager` stay free of shape logic + * and the UI stays free of arithmetic. This is account-wide data (not per-session): + * every live session's SDK stream reports the same limits, so the manager keeps the + * latest window per type and the banner renders them. + */ + +/** The usage windows the SDK reports for a claude.ai subscription. */ +export type RateLimitType = + | 'five_hour' + | 'seven_day' + | 'seven_day_opus' + | 'seven_day_sonnet' + | 'seven_day_overage_included' + | 'overage'; + +/** Whether the account is still being served on a window (`rejected` = turned away). */ +export type RateLimitStatus = 'allowed' | 'allowed_warning' | 'rejected'; + +/** + * Which display label a window maps to. Several SDK types collapse to one + * category (both `seven_day` and `seven_day_overage_included` read as "this week"). + */ +export type RateLimitLabelKey = 'session' | 'week' | 'weekOpus' | 'weekSonnet' | 'overage'; + +/** A single normalized usage window (account-wide) derived from a rate_limit_event. */ +export interface RateLimitWindow { + type: RateLimitType; + status: RateLimitStatus; + /** Percent used (0–100), as reported by the SDK's `utilization`. Undefined if absent. */ + utilization?: number; + /** Epoch **milliseconds** at which this window resets. Undefined if absent. */ + resetsAt?: number; +} + +/** The loosely-typed shape read out of `rate_limit_event.rate_limit_info`. */ +export interface RateLimitInfoJson { + status?: string; + resetsAt?: number; + rateLimitType?: string; + utilization?: number; +} + +const KNOWN_TYPES: readonly RateLimitType[] = [ + 'five_hour', + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_overage_included', + 'overage', +]; + +const KNOWN_STATUSES: readonly RateLimitStatus[] = ['allowed', 'allowed_warning', 'rejected']; + +/** + * Display order + de-dupe priority: the 5-hour "current session" window first, + * then the weekly windows, with pooled overage last. When two types collapse to + * the same display label (e.g. `seven_day` and `seven_day_overage_included` both + * read as "this week"), the one earlier in this list wins so the banner never + * shows two identically-labeled rows. + */ +const PRIORITY: readonly RateLimitType[] = [ + 'five_hour', + 'seven_day', + 'seven_day_sonnet', + 'seven_day_opus', + 'seven_day_overage_included', + 'overage', +]; + +const LABEL_KEYS: Record = { + five_hour: 'session', + seven_day: 'week', + seven_day_overage_included: 'week', + seven_day_sonnet: 'weekSonnet', + seven_day_opus: 'weekOpus', + overage: 'overage', +}; + +function isType(value: string | undefined): value is RateLimitType { + return value !== undefined && (KNOWN_TYPES as readonly string[]).includes(value); +} + +function isStatus(value: string | undefined): value is RateLimitStatus { + return value !== undefined && (KNOWN_STATUSES as readonly string[]).includes(value); +} + +/** + * `resetsAt` arrives as a Unix timestamp in **seconds** (observed in real SDK + * output, e.g. `1785542400`). Normalize to epoch ms. Guard against a future SDK + * that switches to ms: anything already past ~2001 in ms magnitude is treated as + * ms and passed through (a seconds value large enough to trip this is year ~5138, + * so real seconds never collide). + */ +function normalizeResetsAt(resetsAt: number | undefined): number | undefined { + if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt) || resetsAt <= 0) { + return undefined; + } + return resetsAt > 1e11 ? resetsAt : resetsAt * 1000; +} + +/** + * Parse one `rate_limit_info` payload into a normalized window, or undefined when + * it lacks a usable type/status (we never surface a window we can't label). + */ +export function toRateLimitWindow( + info: RateLimitInfoJson | undefined, +): RateLimitWindow | undefined { + if (!info || !isType(info.rateLimitType) || !isStatus(info.status)) { + return undefined; + } + const utilization = + typeof info.utilization === 'number' && + Number.isFinite(info.utilization) && + info.utilization >= 0 + ? info.utilization + : undefined; + return { + type: info.rateLimitType, + status: info.status, + utilization, + resetsAt: normalizeResetsAt(info.resetsAt), + }; +} + +/** The display-label category for a window's type. */ +export function rateLimitLabelKey(type: RateLimitType): RateLimitLabelKey { + return LABEL_KEYS[type]; +} + +/** Two windows are equivalent for churn-avoidance (skip re-render on no-op events). */ +export function sameRateLimitWindow(a: RateLimitWindow, b: RateLimitWindow): boolean { + return ( + a.type === b.type && + a.status === b.status && + a.utilization === b.utilization && + a.resetsAt === b.resetsAt + ); +} + +/** + * Order windows for display (5-hour first, overage last) and collapse any that + * share a display label, keeping the highest-priority type. Guarantees at most + * one row per label so the banner can't render two identical "This week" lines. + */ +export function sortRateLimitWindows(windows: readonly RateLimitWindow[]): RateLimitWindow[] { + const ordered = [...windows].sort((a, b) => PRIORITY.indexOf(a.type) - PRIORITY.indexOf(b.type)); + const seenLabels = new Set(); + return ordered.filter((w) => { + const label = rateLimitLabelKey(w.type); + if (seenLabels.has(label)) { + return false; + } + seenLabels.add(label); + return true; + }); +} + +/** Days/hours/minutes remaining until `resetsAtMs`, clamped at zero (never negative). */ +export interface ResetCountdown { + days: number; + hours: number; + minutes: number; +} + +/** Time remaining until a window resets, split into d/h/m and clamped at zero. */ +export function resetCountdown(resetsAtMs: number, nowMs: number): ResetCountdown { + const totalMinutes = Math.max(0, Math.floor((resetsAtMs - nowMs) / 60000)); + return { + days: Math.floor(totalMinutes / 1440), + hours: Math.floor((totalMinutes % 1440) / 60), + minutes: totalMinutes % 60, + }; +} diff --git a/src/core/session-manager.spec.ts b/src/core/session-manager.spec.ts index 92aa222..fb45727 100644 --- a/src/core/session-manager.spec.ts +++ b/src/core/session-manager.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { RateLimitInfoJson } from '@/core/rate-limit'; import { SessionManager } from '@/core/session-manager'; import type { PrAutomation, SessionHandle, WorktreeService } from '@/core/session-ports'; import { initialState } from '@/core/status-reducer'; @@ -29,9 +30,14 @@ class FakeSession implements SessionHandle { input: CreateSessionInput, private readonly onChange: (s: SessionState) => void, restored?: SessionState, + private readonly onRateLimit?: (info: RateLimitInfoJson) => void, ) { this.state = restored ?? initialState(input); } + /** Simulate the SDK reporting an account-wide usage limit through this session. */ + emitRateLimit(info: RateLimitInfoJson) { + this.onRateLimit?.(info); + } calls: string[] = []; getState() { return this.state; @@ -92,8 +98,8 @@ function makeManager() { throw new Error('should not be called with a fake factory'); }) as never, now: () => 100, - createSession: ({ input, onChange, restored }) => { - const s = new FakeSession(input, onChange, restored); + createSession: ({ input, onChange, restored, onRateLimit }) => { + const s = new FakeSession(input, onChange, restored, onRateLimit); created.push(s); return s; }, @@ -126,6 +132,58 @@ describe('SessionManager', () => { expect(manager.getSnapshot()[0]?.branch).toBe('codiva/add-feature'); }); + it('aggregates rate-limit events into a sorted, account-wide snapshot', async () => { + const { manager, created } = makeManager(); + const listener = vi.fn(); + manager.subscribe(listener); + expect(manager.getRateLimits()).toEqual([]); + + manager.create('task'); + await flush(); + listener.mockClear(); + + // Weekly first, then the 5-hour window — the snapshot must sort five_hour first. + created[0]?.emitRateLimit({ + status: 'allowed', + rateLimitType: 'seven_day', + utilization: 40, + resetsAt: 2000, + }); + created[0]?.emitRateLimit({ + status: 'allowed', + rateLimitType: 'five_hour', + utilization: 5, + resetsAt: 1000, + }); + const windows = manager.getRateLimits(); + expect(windows.map((w) => w.type)).toEqual(['five_hour', 'seven_day']); + expect(windows[0]).toMatchObject({ utilization: 5, resetsAt: 1000_000 }); + expect(listener).toHaveBeenCalled(); + }); + + it('ignores unchanged rate-limit events (stable reference, no re-render)', async () => { + const { manager, created } = makeManager(); + manager.create('task'); + await flush(); + created[0]?.emitRateLimit({ + status: 'allowed', + rateLimitType: 'five_hour', + utilization: 5, + resetsAt: 1000, + }); + const first = manager.getRateLimits(); + const listener = vi.fn(); + manager.subscribe(listener); + created[0]?.emitRateLimit({ + status: 'allowed', + rateLimitType: 'five_hour', + utilization: 5, + resetsAt: 1000, + }); + expect(manager.getRateLimits()).toBe(first); // same reference — no rebuild + expect(listener).not.toHaveBeenCalled(); + }); + it('avoids slug collisions across concurrent creates', async () => { const { manager } = makeManager(); manager.create('feature'); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 101885e..c68509a 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1,6 +1,14 @@ import { errorMessage } from './errors'; import { assemblePersistedState, type PersistedState, restoredSessionState } from './persistence'; import { PrCoordinator } from './pr-coordinator'; +import { + type RateLimitInfoJson, + type RateLimitType, + type RateLimitWindow, + sameRateLimitWindow, + sortRateLimitWindows, + toRateLimitWindow, +} from './rate-limit'; import { createModePolicy, type RunMode } from './run-mode'; import { type PermissionPolicy, type QueryFn, Session, type SessionOptions } from './session'; import { discardSession, mergeSession, sessionDiffStat } from './session-actions'; @@ -52,6 +60,7 @@ export interface SessionManagerDeps { createSession?: (args: { input: CreateSessionInput; onChange: (state: SessionState) => void; + onRateLimit: (info: RateLimitInfoJson) => void; resume?: string; restored?: SessionState; }) => SessionHandle; @@ -85,6 +94,13 @@ export class SessionManager { private readonly worktreeMeta = new Map(); private readonly usedSlugs = new Set(); private readonly prs: PrCoordinator; + /** + * Latest account-wide subscription usage per window type (claude.ai limits). + * Every live session reports the same limits, so we keep the newest per type + * and expose a sorted snapshot for the banner. Transient — never persisted. + */ + private readonly rateLimits = new Map(); + private rateLimitSnapshot: RateLimitWindow[] = []; private seq = 0; private mode: RunMode = 'auto'; private readonly now: () => number; @@ -149,6 +165,31 @@ export class SessionManager { return this.store.getSnapshot(); } + /** + * Account-wide claude.ai subscription usage windows (5-hour + weekly), newest + * per type, in display order. Empty until the SDK reports a limit (Console/API + * keys never do). The reference is stable across no-op events so the banner + * subscription doesn't churn. + */ + getRateLimits(): RateLimitWindow[] { + return this.rateLimitSnapshot; + } + + /** Fold a session's `rate_limit_event` into the account-wide snapshot. */ + private onRateLimit(info: RateLimitInfoJson): void { + const window = toRateLimitWindow(info); + if (!window) { + return; + } + const prev = this.rateLimits.get(window.type); + if (prev && sameRateLimitWindow(prev, window)) { + return; // unchanged — don't rebuild the snapshot or re-render + } + this.rateLimits.set(window.type, window); + this.rateLimitSnapshot = sortRateLimitWindows([...this.rateLimits.values()]); + this.store.notify(); + } + get(id: string): SessionState | undefined { return this.store.get(id); } @@ -179,8 +220,9 @@ export class SessionManager { extra?: { resume?: string; restored?: SessionState }, ): SessionHandle { const onChange = (s: SessionState) => this.onSessionChange(input.id, s); + const onRateLimit = (info: RateLimitInfoJson) => this.onRateLimit(info); if (this.deps.createSession) { - return this.deps.createSession({ input, onChange, ...extra }); + return this.deps.createSession({ input, onChange, onRateLimit, ...extra }); } return new Session({ queryFn: this.deps.queryFn, @@ -189,6 +231,7 @@ export class SessionManager { now: this.now, policy: this.deps.policy ?? this.modePolicy, onChange, + onRateLimit, generateTitle: extra ? undefined : this.deps.generateTitle, resume: extra?.resume, restored: extra?.restored, diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 976dc35..232cfc0 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -90,6 +90,37 @@ describe('Session', () => { expect(states).toContain('completed'); }); + it('forwards rate_limit_event payloads to onRateLimit', async () => { + const fake = makeFakeQuery(); + const infos: unknown[] = []; + const session = new Session({ + queryFn: fake.queryFn, + input: INPUT, + now: () => 1, + onRateLimit: (info) => infos.push(info), + }); + session.start(); + fake.emit(initMsg()); + fake.emit({ + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed_warning', + rateLimitType: 'five_hour', + utilization: 5, + resetsAt: 1785542400, + }, + } as unknown as SDKMessage); + await tick(); + expect(infos).toEqual([ + { + status: 'allowed_warning', + rateLimitType: 'five_hour', + utilization: 5, + resetsAt: 1785542400, + }, + ]); + }); + it('escalates AskUserQuestion and resolves it with answers', async () => { const fake = makeFakeQuery(); const session = new Session({ queryFn: fake.queryFn, input: INPUT, now: () => 1 }); diff --git a/src/core/session.ts b/src/core/session.ts index e3d6c19..dcc2161 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -9,6 +9,7 @@ import type { } from '@anthropic-ai/claude-agent-sdk'; import { AsyncQueue } from './async-queue'; import { errorMessage } from './errors'; +import type { RateLimitInfoJson } from './rate-limit'; import { applySdkMessage } from './sdk-parse'; import { initialState, reduce } from './status-reducer'; import type { @@ -55,6 +56,13 @@ export interface SessionDeps { now?: () => number; policy?: PermissionPolicy; onChange?: (state: SessionState) => void; + /** + * Called with the raw `rate_limit_info` whenever the SDK emits a + * `rate_limit_event`. This is account-wide (claude.ai subscription) usage data, + * not per-session, so the manager keeps the latest per window type and the + * banner renders it. Injected so the session stays a pure stream consumer. + */ + onRateLimit?: (info: RateLimitInfoJson) => void; /** * Optional title generator. When provided, a fresh session asks it to * summarize the initial prompt into a short title (à la Claude Code's tab @@ -314,9 +322,15 @@ export class Session { }, }); for await (const message of this.handle) { + const msg = message as SDKMessage; + // Account-wide subscription usage is surfaced out-of-band (it isn't + // per-session state) so the manager can aggregate it for the banner. + if (msg.type === 'rate_limit_event') { + this.deps.onRateLimit?.(msg.rate_limit_info); + } // Raw SDK output is folded straight into state by sdk-parse (not routed // through the reducer's event union) — see core/sdk-parse.ts. - this.commit(applySdkMessage(this.state, message as SDKMessage, this.now())); + this.commit(applySdkMessage(this.state, msg, this.now())); } } catch (err) { if (!this.abortController.signal.aborted) { diff --git a/src/ui/banner.spec.tsx b/src/ui/banner.spec.tsx index bdf3fb1..da129e5 100644 --- a/src/ui/banner.spec.tsx +++ b/src/ui/banner.spec.tsx @@ -46,4 +46,51 @@ describe('Banner', () => { const frame = lastFrame() ?? ''; expect(frame.indexOf('model: sonnet')).toBeLessThan(frame.indexOf('/tmp/repo')); }); + + it('サブスクリプション使用リミットが無ければ Usage 節を出さない', () => { + const { lastFrame } = renderBanner({ sessionCount: 0 }, 'en'); + expect(lastFrame()).not.toContain('Usage'); + }); + + it('5時間枠の使用率とリセットまでの残り時間を表示する', () => { + const now = 1_000_000_000_000; + const { lastFrame } = renderBanner( + { + sessionCount: 0, + now, + rateLimits: [ + { + type: 'five_hour', + status: 'allowed', + utilization: 5, + resetsAt: now + (4 * 60 + 45) * 60_000, // 4h45m out + }, + ], + }, + 'en', + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Usage'); + expect(frame).toContain('Current session'); + expect(frame).toContain('5% used'); + expect(frame).toContain('resets in 4h 45m'); + }); + + it('日本語では現在のセッションと使用率を日本語で表示する', () => { + const now = 1_000_000_000_000; + const { lastFrame } = renderBanner( + { + sessionCount: 0, + now, + rateLimits: [ + { type: 'five_hour', status: 'allowed', utilization: 5, resetsAt: now + 285 * 60_000 }, + ], + }, + 'ja', + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('現在のセッション'); + expect(frame).toContain('5% 使用'); + expect(frame).toContain('4時間45分後にリセット'); + }); }); diff --git a/src/ui/banner.tsx b/src/ui/banner.tsx index 558fc3b..937ab93 100644 --- a/src/ui/banner.tsx +++ b/src/ui/banner.tsx @@ -1,8 +1,14 @@ import { Box, Text } from 'ink'; import type { FC } from 'react'; -import { formatUsd } from '@/core'; +import { + formatUsd, + type Messages, + type RateLimitWindow, + rateLimitLabelKey, + resetCountdown, +} from '@/core'; import { useMessages } from './i18n-context'; -import { palette } from './theme'; +import { palette, statusColor } from './theme'; // codiva mascot. Each glyph is rendered in its own , so you can paint it // one character at a time via paint() below. @@ -45,6 +51,60 @@ const LOGO_ROWS = LOGO.map((line, row) => ({ cells: [...line].map((ch, col) => ({ key: `${row}:${col}`, ch, row, col })), })); +/** Semantic color for a usage window: red when rejected, amber on warning, dim otherwise. */ +function usageColor(status: RateLimitWindow['status']): string | undefined { + if (status === 'rejected') { + return statusColor.failed; + } + if (status === 'allowed_warning') { + return statusColor.awaitingPermission; + } + return undefined; // 'allowed' — falls back to the dim default +} + +/** Build the "5% used · resets in 4h45m" trailing detail for a usage window. */ +function usageDetail(m: Messages, window: RateLimitWindow, now: number): string { + const parts: string[] = []; + if (window.utilization !== undefined) { + parts.push(m.banner.usage.used(Math.round(window.utilization))); + } + if (window.resetsAt !== undefined) { + const { days, hours, minutes } = resetCountdown(window.resetsAt, now); + parts.push(m.banner.usage.resetsIn(days, hours, minutes)); + } + return parts.join(' · '); +} + +/** + * The claude.ai subscription usage section (5-hour "current session" + weekly + * windows). Renders nothing when the SDK reports no limits (Console/API keys), so + * it's invisible to non-subscription users. + */ +const UsageSection: FC<{ windows: readonly RateLimitWindow[]; now: number }> = ({ + windows, + now, +}) => { + const m = useMessages(); + if (windows.length === 0) { + return null; + } + return ( + + {m.banner.usage.heading} + {windows.map((w) => { + const label = m.banner.usage[rateLimitLabelKey(w.type)]; + const detail = usageDetail(m, w, now); + const color = usageColor(w.status); + return ( + + {` ${label}${detail ? ` ${detail}` : ''}`} + + ); + })} + + ); +}; + /** * Borderless startup header echoing Claude Code's banner: the mascot on the left * and identity / subtitle / cwd on the right (vertically centered against it). @@ -56,7 +116,11 @@ export const Banner: FC<{ version?: string; sessionCount: number; totalCostUsd?: number; -}> = ({ cwd, model, version, sessionCount, totalCostUsd = 0 }) => { + /** claude.ai サブスクリプションの使用リミット枠(SDK 由来。空なら非表示)。 */ + rateLimits?: readonly RateLimitWindow[]; + /** リセットまでの残り時間を算出する基準時刻(ms)。省略時は現在時刻。 */ + now?: number; +}> = ({ cwd, model, version, sessionCount, totalCostUsd = 0, rateLimits = [], now }) => { const m = useMessages(); return ( @@ -86,6 +150,7 @@ export const Banner: FC<{ {m.banner.subtitle} {m.banner.model(model ?? m.banner.defaultModel)} {cwd ? {cwd} : null} + ); diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts index ebd791a..5fc282a 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -10,6 +10,7 @@ import { import { type CommandAction, emptyBuffer, + type RateLimitWindow, type RunMode, runCommand, type SessionManager, @@ -54,6 +55,19 @@ export function useRunMode(manager: SessionManager): RunMode { ); } +/** + * Subscribe to the account-wide claude.ai subscription usage windows. The manager + * returns a stable array reference across no-op events, so this only re-renders + * when a window actually changes (safe for useSyncExternalStore). + */ +export function useRateLimit(manager: SessionManager): RateLimitWindow[] { + return useSyncExternalStore( + (onChange) => manager.subscribe(onChange), + () => manager.getRateLimits(), + () => manager.getRateLimits(), + ); +} + /** Position of a box relative to the Ink output origin (terminal cells). */ export interface AbsolutePosition { left: number; diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 66bbf7f..57b8e2e 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -32,6 +32,7 @@ import { useClock, useCommandRunner, useLifecycleAction, + useRateLimit, useRunMode, useSessions, useTextBufferRef, @@ -115,6 +116,7 @@ export const SessionList: FC<{ const m = useMessages(); const sessions = useSessions(manager); const mode = useRunMode(manager); + const rateLimits = useRateLimit(manager); const now = useClock(1000); // 端末幅は PR セル(行末の固定幅列)のクリック当たり判定に、端末高は一覧の // 内部スクロール(収まる行数の算出)に使う。いずれもリサイズ追従。 @@ -378,6 +380,8 @@ export const SessionList: FC<{ version={version} sessionCount={sessions.length} totalCostUsd={totalCostUsd(sessions)} + rateLimits={rateLimits} + now={now} /> {/* flexGrow で残り高さを占め、入力欄とフッタを画面最下部へ押し下げる。