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
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: '自動モード',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
137 changes: 137 additions & 0 deletions src/core/rate-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading