diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..3028caf --- /dev/null +++ b/PLAN.md @@ -0,0 +1,202 @@ +# Model-Specific Usage Tracking Implementation Plan + +## Overview + +Add support for model-specific weekly usage limits (Opus, Sonnet) with three display modes: +- **Simple**: Current behavior (overall only) +- **Detailed**: Show Overall, Opus, Sonnet side by side +- **Smart/Bottleneck**: Show the most restrictive limit dynamically + +## API Response Structure + +```json +{ + "five_hour": { "utilization": 29.0, "resets_at": "..." }, + "seven_day": { "utilization": 47.0, "resets_at": "..." }, + "seven_day_opus": { "utilization": 15.0, "resets_at": "..." } | null, + "seven_day_sonnet": { "utilization": 7.0, "resets_at": "..." } | null +} +``` + +## Implementation Steps + +### 1. Update `src/utils/oauth.ts` + +**Changes:** +- Update `ApiResponse` interface to include `seven_day_opus` and `seven_day_sonnet` +- Update `OAuthUsageResponse` interface to include model-specific data: + ```typescript + export interface OAuthUsageResponse { + fiveHour: UsageData | null; + sevenDay: UsageData | null; + sevenDayOpus: UsageData | null; + sevenDaySonnet: UsageData | null; + raw?: unknown; + } + ``` +- Update `fetchUsageFromAPI` to parse the new fields +- Update `TrendInfo` to include model-specific trends (optional, can be done later) + +### 2. Update `src/config/types.ts` + +**Changes:** +- Add new `WeeklyViewMode` type: + ```typescript + export type WeeklyViewMode = "simple" | "detailed" | "smart"; + ``` +- Update `WeeklySegmentConfig` to include view mode: + ```typescript + export interface WeeklySegmentConfig extends SegmentConfig { + showWeekProgress?: boolean; + viewMode?: WeeklyViewMode; // New: default "simple" + } + ``` +- Update `DEFAULT_CONFIG` with `viewMode: "simple"` + +### 3. Update `src/segments/weekly.ts` + +**Changes:** +- Update `WeeklyInfo` interface to include model-specific data: + ```typescript + export interface WeeklyInfo { + percentUsed: number | null; + resetAt: Date | null; + isRealtime: boolean; + weekProgressPercent: number; + // New model-specific fields + opusPercentUsed: number | null; + sonnetPercentUsed: number | null; + opusResetAt: Date | null; + sonnetResetAt: Date | null; + } + ``` +- Update `getRealtimeWeeklyInfo` to extract and return model-specific data + +### 4. Update `src/renderer.ts` + +**Changes:** +- Update `renderWeekly` method to handle three view modes: + + **Simple mode** (current behavior): + ``` + ○ 47% (wk 85%) + ``` + + **Detailed mode** (show all three): + ``` + ○ 47% | ◈15% | ◇7% + ``` + Where ○ = Overall, ◈ = Opus, ◇ = Sonnet + + Or use labels: + ``` + ○ All:47% Op:15% So:7% + ``` + + **Smart/Bottleneck mode**: + - Show whichever is highest percentage (closest to limit) + - Add indicator of which limit is shown + - Example: `○ 47%▲` (▲ indicates this is the bottleneck) + - Or show model name: `○ 47% (all)` vs `◈ 85% (opus)` + +### 5. Update `src/themes/index.ts` + +**Changes:** +- Add colors for Opus and Sonnet segments (optional, can reuse existing): + ```typescript + export interface ColorTheme { + // ... existing + opus: SegmentColor; // New + sonnet: SegmentColor; // New + } + ``` + +### 6. Update `src/utils/constants.ts` + +**Changes:** +- Add symbols for model-specific indicators: + ```typescript + export const SYMBOLS = { + // ... existing + opus: "\u25c8", // ◈ Diamond with dot + sonnet: "\u25c7", // ◇ White diamond + }; + ``` + +### 7. Update Tests + +**Files to update:** +- `src/utils/oauth.test.ts` - Add tests for new API fields +- `src/segments/weekly.test.ts` - Add tests for model-specific data +- `src/renderer.test.ts` - Add tests for three view modes + +### 8. Update Documentation + +**Files to update:** +- `README.md` - Document new config option and view modes +- `.claude-limitline.example.json` - Add viewMode example + +## Configuration Example + +```json +{ + "weekly": { + "enabled": true, + "displayStyle": "text", + "showWeekProgress": true, + "viewMode": "smart" + } +} +``` + +## Display Examples + +### Simple Mode (default) +``` + claude-limitline main Opus 4.5 ◫ 29% ○ 47% +``` + +### Detailed Mode +``` + claude-limitline main Opus 4.5 ◫ 29% ○ 47% ◈ 15% ◇ 7% +``` +Or as separate segments (if user wants): +``` + claude-limitline main Opus 4.5 ◫ 29% ○ 47% ◈ 15% ◇ 7% +``` + +### Smart Mode +Shows the most restrictive limit with an indicator: +``` + claude-limitline main Opus 4.5 ◫ 29% ○ 47%* +``` +Where `*` or similar indicates this is the bottleneck. + +If Opus was at 90%: +``` + claude-limitline main Opus 4.5 ◫ 29% ◈ 90%* +``` + +## Edge Cases + +1. **Opus/Sonnet is null** (user on Pro without Opus access): + - Simple: Show overall only + - Detailed: Show only available limits + - Smart: Show overall (it's the only limit) + +2. **All limits are equal**: Smart mode shows overall by default + +3. **Compact mode**: + - Simple: `47%` + - Detailed: `47/15/7%` (compact format) + - Smart: `47%*` + +## Implementation Order + +1. `oauth.ts` - Parse new API fields (foundation) +2. `config/types.ts` - Add config types +3. `segments/weekly.ts` - Update WeeklyInfo +4. `constants.ts` & `themes/index.ts` - Add symbols/colors +5. `renderer.ts` - Implement three view modes +6. Tests - Add coverage +7. Documentation - Update README diff --git a/README.md b/README.md index 3817717..bead606 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Create a `claude-limitline.json` file in your Claude config directory (`~/.claud | `weekly.displayStyle` | `"bar"` or `"text"` | `"text"` | | `weekly.barWidth` | Width of progress bar in characters | `10` | | `weekly.showWeekProgress` | Show week progress percentage | `true` | -| `weekly.viewMode` | `"simple"`, `"detailed"`, or `"smart"` | `"simple"` | +| `weekly.viewMode` | `"simple"` or `"smart"` | `"simple"` | | `budget.pollInterval` | Minutes between API calls | `15` | | `budget.warningThreshold` | Percentage to trigger warning color | `80` | | `theme` | Color theme name | `"dark"` | @@ -132,15 +132,16 @@ Create a `claude-limitline.json` file in your Claude config directory (`~/.claud ### Weekly View Modes -The weekly segment supports three view modes for displaying usage limits: +The weekly segment supports two view modes for displaying usage limits: + +![Mode Preview](imgs/mode-preview.png) | Mode | Description | Example | |------|-------------|---------| | `simple` | Shows overall weekly usage only (default) | `○ 47% (wk 85%)` | -| `detailed` | Shows overall, Opus, and Sonnet usage side by side | `○47% ◈15% ◇7%` | -| `smart` | Shows the most restrictive (bottleneck) limit with indicator | `○47%▲ (wk 85%)` | +| `smart` | Model-aware: shows Sonnet + Overall when using Sonnet | `◇7% \| ○47% (wk 85%)` | -**Note:** Model-specific limits (Opus/Sonnet) are only available on certain subscription tiers. When a model-specific limit is not available, it will be hidden from the display. +**Note:** Model-specific limits (Opus/Sonnet) are only available on certain subscription tiers. Smart mode shows only overall usage when using Opus/Haiku, and shows both Sonnet and overall when using Sonnet. ### Available Themes @@ -190,7 +191,7 @@ npm run dev # Watch mode ## Testing -The project uses [Vitest](https://vitest.dev/) for testing with 166 tests covering config loading, themes, segments, utilities, and rendering. +The project uses [Vitest](https://vitest.dev/) for testing with 164 tests covering config loading, themes, segments, utilities, and rendering. ```bash npm test # Run tests once @@ -205,13 +206,13 @@ npm run test:coverage # Coverage report | `src/config/loader.test.ts` | 7 | Config loading, merging, fallbacks | | `src/themes/index.test.ts` | 37 | Theme retrieval, color validation | | `src/segments/block.test.ts` | 8 | Block segment, time calculations | -| `src/segments/weekly.test.ts` | 10 | Weekly segment, week progress | -| `src/utils/oauth.test.ts` | 10 | API responses, caching | +| `src/segments/weekly.test.ts` | 13 | Weekly segment, week progress | +| `src/utils/oauth.test.ts` | 13 | API responses, caching, trends | | `src/utils/claude-hook.test.ts` | 21 | Model name formatting | | `src/utils/environment.test.ts` | 20 | Git branch, directory detection | | `src/utils/terminal.test.ts` | 13 | Terminal width, ANSI handling | | `src/utils/logger.test.ts` | 8 | Debug/error logging | -| `src/renderer.test.ts` | 21 | Segment rendering, ordering | +| `src/renderer.test.ts` | 24 | Segment rendering, ordering, view modes | ## Debug Mode diff --git a/demo-modes.mjs b/demo-modes.mjs new file mode 100644 index 0000000..501843a --- /dev/null +++ b/demo-modes.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Demo script to showcase the three weekly view modes + * Outputs raw ANSI-styled text to demonstrate the modes + */ + +// ANSI color helpers +const ansi = { + fg: (n) => `\x1b[38;5;${n}m`, + bg: (n) => `\x1b[48;5;${n}m`, + reset: '\x1b[0m', +}; + +// Powerline arrow +const arrow = '\ue0b0'; + +// Symbols +const symbols = { + block: '◫', + weekly: '○', + sonnet: '◇', + branch: '\ue0a0', + model: '✱', +}; + +// Dark theme colors (approximate ANSI 256 values) +const colors = { + directory: { bg: 94, fg: 231 }, // Brown bg, white fg + git: { bg: 238, fg: 231 }, // Dark gray bg, white fg + model: { bg: 236, fg: 231 }, // Darker gray bg, white fg + block: { bg: 235, fg: 117 }, // Dark bg, cyan fg + weekly: { bg: 234, fg: 120 }, // Darker bg, green fg +}; + +function segment(text, color, nextColor = null) { + let out = ansi.bg(color.bg) + ansi.fg(color.fg) + text + ansi.reset; + if (nextColor) { + out += ansi.fg(color.bg) + ansi.bg(nextColor.bg) + arrow; + } else { + out += ansi.fg(color.bg) + arrow; + } + return out + ansi.reset; +} + +function renderStatusline(weeklyText, weeklyColor = colors.weekly) { + return ( + segment(` claude-limitline `, colors.directory, colors.git) + + segment(` ${symbols.branch} main `, colors.git, colors.model) + + segment(` ${symbols.model} Opus 4.5 `, colors.model, colors.block) + + segment(` ${symbols.block} 29% (3h) `, colors.block, weeklyColor) + + segment(weeklyText, weeklyColor) + ); +} + +console.log("\n┌─────────────────────────────────────────────────────────────┐"); +console.log("│ Weekly View Modes Demo │"); +console.log("└─────────────────────────────────────────────────────────────┘\n"); + +// Simple mode +console.log('\x1b[1mSimple Mode\x1b[0m (default):'); +console.log('Shows overall weekly usage only'); +console.log('Config: { "weekly": { "viewMode": "simple" } }\n'); +console.log(renderStatusline(` ${symbols.weekly} 47% (wk 85%) `)); +console.log("\n"); + +// Smart mode - Opus selected +console.log('\x1b[1mSmart Mode\x1b[0m (Opus selected):'); +console.log('When using Opus, shows only Overall usage'); +console.log('Config: { "weekly": { "viewMode": "smart" } }\n'); +console.log(renderStatusline(` ${symbols.weekly}47% (wk 85%) `)); +console.log("\n"); + +// Smart mode - Sonnet selected +console.log('\x1b[1mSmart Mode\x1b[0m (Sonnet selected):'); +console.log('When using Sonnet, shows Sonnet | Overall format'); +console.log('Config: { "weekly": { "viewMode": "smart" } }\n'); +console.log( + segment(` claude-limitline `, colors.directory, colors.git) + + segment(` ${symbols.branch} main `, colors.git, colors.model) + + segment(` ${symbols.model} Sonnet 4 `, colors.model, colors.block) + + segment(` ${symbols.block} 29% (3h) `, colors.block, colors.weekly) + + segment(` ${symbols.sonnet}7% | ${symbols.weekly}47% (wk 85%) `, colors.weekly) +); +console.log("\n"); + +console.log("─".repeat(62)); +console.log("Note: Model-specific limits (Opus/Sonnet) are only available"); +console.log("on certain subscription tiers.\n"); diff --git a/src/config/types.ts b/src/config/types.ts index 92cb0c9..3d4e63e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -12,7 +12,7 @@ export interface BlockSegmentConfig extends SegmentConfig { showTimeRemaining?: boolean; } -export type WeeklyViewMode = "simple" | "detailed" | "smart"; +export type WeeklyViewMode = "simple" | "smart"; export interface WeeklySegmentConfig extends SegmentConfig { showWeekProgress?: boolean; diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 23befdf..ce0a1cc 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -342,23 +342,7 @@ describe("Renderer", () => { expect(output).not.toContain("◇"); }); - it("shows all model percentages in detailed mode", () => { - const config: LimitlineConfig = { - ...DEFAULT_CONFIG, - weekly: { - enabled: true, - viewMode: "detailed", - }, - }; - const renderer = new Renderer(config); - const output = renderer.render(defaultBlockInfo, weeklyInfoWithModelData, defaultEnvInfo); - - expect(output).toContain("47%"); - expect(output).toContain("15%"); - expect(output).toContain("7%"); - }); - - it("shows bottleneck (highest) percentage in smart mode", () => { + it("smart mode shows only overall when using Opus", () => { const config: LimitlineConfig = { ...DEFAULT_CONFIG, weekly: { @@ -367,15 +351,20 @@ describe("Renderer", () => { }, }; const renderer = new Renderer(config); + // defaultEnvInfo has model: "Opus 4.5" const output = renderer.render(defaultBlockInfo, weeklyInfoWithModelData, defaultEnvInfo); - // Overall (47%) is the bottleneck here + // Should show overall percentage expect(output).toContain("47%"); - // Should show bottleneck indicator - expect(output).toContain("▲"); + // Should show week progress + expect(output).toContain("wk 75%"); + // Should NOT show Sonnet-specific data or bottleneck indicator + expect(output).not.toContain("◇"); + expect(output).not.toContain("▲"); + expect(output).not.toContain("|"); }); - it("shows model-specific bottleneck when it is the highest", () => { + it("smart mode shows Sonnet | Overall when using Sonnet", () => { const config: LimitlineConfig = { ...DEFAULT_CONFIG, weekly: { @@ -384,48 +373,19 @@ describe("Renderer", () => { }, }; const renderer = new Renderer(config); - const weeklyInfoOpusHigh: WeeklyInfo = { - percentUsed: 30, - weekProgressPercent: 75, - resetAt: new Date(), - isRealtime: true, - opusPercentUsed: 85, // Opus is the bottleneck - sonnetPercentUsed: 10, - opusResetAt: new Date(), - sonnetResetAt: new Date(), - }; - const output = renderer.render(defaultBlockInfo, weeklyInfoOpusHigh, defaultEnvInfo); - - expect(output).toContain("85%"); - expect(output).toContain("▲"); - }); - - it("detailed mode hides unavailable model limits", () => { - const config: LimitlineConfig = { - ...DEFAULT_CONFIG, - weekly: { - enabled: true, - viewMode: "detailed", - }, - }; - const renderer = new Renderer(config); - const weeklyInfoOnlySonnet: WeeklyInfo = { - percentUsed: 47, - weekProgressPercent: 75, - resetAt: new Date(), - isRealtime: true, - opusPercentUsed: null, - sonnetPercentUsed: 7, - opusResetAt: null, - sonnetResetAt: new Date(), + const sonnetEnvInfo: EnvironmentInfo = { + ...defaultEnvInfo, + model: "Sonnet 4", }; - const output = renderer.render(defaultBlockInfo, weeklyInfoOnlySonnet, defaultEnvInfo); + const output = renderer.render(defaultBlockInfo, weeklyInfoWithModelData, sonnetEnvInfo); - // Should show overall and sonnet - expect(output).toContain("47%"); + // Should show both Sonnet (7%) and Overall (47%) with separator expect(output).toContain("7%"); - // Should not show opus - expect(output).not.toContain("◈"); + expect(output).toContain("47%"); + expect(output).toContain("|"); + // Should show week progress + expect(output).toContain("wk 75%"); }); + }); }); diff --git a/src/renderer.ts b/src/renderer.ts index 7c52cb2..5f96f14 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -279,132 +279,53 @@ export class Renderer { }; } - private renderWeeklyDetailed(ctx: RenderContext): Segment | null { + private renderWeeklySmart(ctx: RenderContext): Segment | null { const info = ctx.weeklyInfo!; const overallIcon = this.usePowerline ? this.symbols.weekly : "All"; - const opusIcon = this.usePowerline ? this.symbols.opus : "Op"; const sonnetIcon = this.usePowerline ? this.symbols.sonnet : "So"; + const showWeekProgress = this.config.weekly?.showWeekProgress ?? true; - // Build parts for each available limit - const parts: string[] = []; + // Detect current model from environment + const currentModel = ctx.envInfo.model?.toLowerCase() ?? ""; + const isSonnet = currentModel.includes("sonnet"); - // Overall - if (info.percentUsed !== null) { - const trend = this.getTrendSymbol(ctx.trendInfo?.sevenDayTrend ?? null); - parts.push(`${overallIcon}${Math.round(info.percentUsed)}%${trend}`); - } + // If using Sonnet and we have Sonnet-specific data, show: Sonnet | Overall + if (isSonnet && info.sonnetPercentUsed !== null && info.percentUsed !== null) { + const sonnetTrend = this.getTrendSymbol(ctx.trendInfo?.sevenDaySonnetTrend ?? null); + const overallTrend = this.getTrendSymbol(ctx.trendInfo?.sevenDayTrend ?? null); - // Opus - if (info.opusPercentUsed !== null) { - const trend = this.getTrendSymbol(ctx.trendInfo?.sevenDayOpusTrend ?? null); - parts.push(`${opusIcon}${Math.round(info.opusPercentUsed)}%${trend}`); - } + let text = `${sonnetIcon}${Math.round(info.sonnetPercentUsed)}%${sonnetTrend} | ${overallIcon}${Math.round(info.percentUsed)}%${overallTrend}`; - // Sonnet - if (info.sonnetPercentUsed !== null) { - const trend = this.getTrendSymbol(ctx.trendInfo?.sevenDaySonnetTrend ?? null); - parts.push(`${sonnetIcon}${Math.round(info.sonnetPercentUsed)}%${trend}`); - } + if (showWeekProgress && !ctx.compact) { + text += ` (wk ${info.weekProgressPercent}%)`; + } + + // Use warning/critical colors based on highest percentage + const maxPercent = Math.max(info.sonnetPercentUsed, info.percentUsed); + const colors = this.getColorsForPercent(maxPercent, this.theme.weekly); - if (parts.length === 0) { return { - text: ` ${overallIcon} -- `, - colors: this.theme.weekly, + text: ` ${text} `, + colors, }; } - // In compact mode, use shorter separator - const separator = ctx.compact ? " " : " "; - const text = parts.join(separator); - - // Use overall colors but could be enhanced with warning colors if any is high - const maxPercent = Math.max( - info.percentUsed ?? 0, - info.opusPercentUsed ?? 0, - info.sonnetPercentUsed ?? 0 - ); - const colors = this.getColorsForPercent(maxPercent, this.theme.weekly); - - return { - text: ` ${text} `, - colors, - }; - } - - private renderWeeklySmart(ctx: RenderContext): Segment | null { - const info = ctx.weeklyInfo!; - const overallIcon = this.usePowerline ? this.symbols.weekly : "All"; - const opusIcon = this.usePowerline ? this.symbols.opus : "Op"; - const sonnetIcon = this.usePowerline ? this.symbols.sonnet : "So"; - - // Find the bottleneck (highest percentage) - interface LimitInfo { - name: string; - icon: string; - percent: number; - trend: "up" | "down" | "same" | null; - colors: SegmentColor; - } - - const limits: LimitInfo[] = []; - - if (info.percentUsed !== null) { - limits.push({ - name: "all", - icon: overallIcon, - percent: info.percentUsed, - trend: ctx.trendInfo?.sevenDayTrend ?? null, - colors: this.theme.weekly, - }); - } - - if (info.opusPercentUsed !== null) { - limits.push({ - name: "opus", - icon: opusIcon, - percent: info.opusPercentUsed, - trend: ctx.trendInfo?.sevenDayOpusTrend ?? null, - colors: this.theme.opus, - }); - } - - if (info.sonnetPercentUsed !== null) { - limits.push({ - name: "sonnet", - icon: sonnetIcon, - percent: info.sonnetPercentUsed, - trend: ctx.trendInfo?.sevenDaySonnetTrend ?? null, - colors: this.theme.sonnet, - }); - } - - if (limits.length === 0) { + // For Opus, Haiku, or when no model-specific data: just show overall + if (info.percentUsed === null) { return { text: ` ${overallIcon} -- `, colors: this.theme.weekly, }; } - // Find the bottleneck (highest percentage) - const bottleneck = limits.reduce((a, b) => (a.percent >= b.percent ? a : b)); - - const trend = this.getTrendSymbol(bottleneck.trend); - const bottleneckIndicator = limits.length > 1 ? this.symbols.bottleneck : ""; - - // Show percentage with bottleneck indicator - let text = `${bottleneck.icon}${Math.round(bottleneck.percent)}%${trend}`; - if (bottleneckIndicator && !ctx.compact) { - text += bottleneckIndicator; - } + const trend = this.getTrendSymbol(ctx.trendInfo?.sevenDayTrend ?? null); + let text = `${overallIcon}${Math.round(info.percentUsed)}%${trend}`; - // Add week progress if enabled and not compact - const showWeekProgress = this.config.weekly?.showWeekProgress ?? true; if (showWeekProgress && !ctx.compact) { text += ` (wk ${info.weekProgressPercent}%)`; } - // Use colors based on bottleneck percentage - const colors = this.getColorsForPercent(bottleneck.percent, bottleneck.colors); + const colors = this.getColorsForPercent(info.percentUsed, this.theme.weekly); return { text: ` ${text} `, @@ -420,8 +341,6 @@ export class Renderer { const viewMode = this.config.weekly?.viewMode ?? "simple"; switch (viewMode) { - case "detailed": - return this.renderWeeklyDetailed(ctx); case "smart": return this.renderWeeklySmart(ctx); case "simple":