From e0df6fdf9086420364b1fa01cfb70a8a2dbbc45c Mon Sep 17 00:00:00 2001 From: Aster Date: Mon, 19 Jan 2026 13:03:12 +0900 Subject: [PATCH] feat: add usageBarEnabled config option for quota display style (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add usageBarEnabled config option for quota display style Add configurable display style for usage limits: - usageBarEnabled: true → visual bar (██░░ 25%) - usageBarEnabled: false → text format (5h: 25%) Changes: - config.ts: Add usageBarEnabled option (default: true) - colors.ts: Add quotaBar() and getQuotaColor() with blue color scheme - usage.ts, session-line.ts: Conditional rendering based on config Closes #84 * fix: add clamp guard to coloredBar for consistency Apply the same safeWidth/safePercent guards from quotaBar to coloredBar to prevent RangeError on malformed input values. Co-Authored-By: Claude Opus 4.5 * chore: revert dist files to main Remove build artifacts from PR diff. CI will rebuild dist/ after merge. Co-Authored-By: Claude Opus 4.5 * test: add usageBarEnabled: false to baseContext Ensures existing tests continue to check text format behavior. The new bar format is opt-in via config. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Aster Kim Co-authored-by: Jarrod Watts Co-authored-by: Claude Opus 4.5 --- README.md | 2 + commands/configure.md | 22 ++++++++-- src/config.ts | 5 +++ src/render/colors.ts | 27 +++++++++++-- src/render/index.ts | 11 +++-- src/render/lines/identity.ts | 78 +++++++++++++++++++++++++++++++++++- src/render/lines/usage.ts | 22 +++++++--- src/render/session-line.ts | 22 +++++++--- tests/render.test.js | 2 +- 9 files changed, 168 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 85c5a14..9dbb6b9 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ You can also edit the config file directly at `~/.claude/plugins/claude-hud/conf | `display.showConfigCounts` | boolean | true | Show CLAUDE.md, rules, MCPs, hooks counts | | `display.showDuration` | boolean | true | Show session duration `⏱️ 5m` | | `display.showUsage` | boolean | true | Show usage limits (Pro/Max/Team only) | +| `display.usageBarEnabled` | boolean | true | Display usage as visual bar (`██░░ 25%`) instead of text (`5h: 25%`) | | `display.showTokenBreakdown` | boolean | true | Show token details at high context (85%+) | | `display.showTools` | boolean | true | Show tools activity line | | `display.showAgents` | boolean | true | Show agents activity line | @@ -217,6 +218,7 @@ To disable usage display, set `display.showUsage` to `false` in your config. "showConfigCounts": true, "showDuration": true, "showUsage": true, + "usageBarEnabled": true, "showTokenBreakdown": true, "showTools": true, "showAgents": true, diff --git a/commands/configure.md b/commands/configure.md index d7e3a4f..0bf9c64 100644 --- a/commands/configure.md +++ b/commands/configure.md @@ -23,7 +23,7 @@ These are always enabled and NOT configurable: Questions: **Layout → Preset → Turn Off → Turn On** ### Flow B: Update Config (config exists) -Questions: **Turn Off → Turn On → Git Style → Layout/Reset** +Questions: **Turn Off → Turn On → Git Style → Layout/Reset** (4 questions max) --- @@ -84,6 +84,7 @@ If preset has all items OFF (Minimal), Q3 shows "Nothing to disable - Minimal pr - "Agents status" - ◐ explore [haiku]: Finding code - "Todo progress" - ▸ Fix bug (2/5 tasks) - "Git status" - git:(main*) branch indicator + - "Usage bar style" - ██░░ 25% visual bar (only if usageBarEnabled is true) If more than 4 items ON, show Activity items (Tools, Agents, Todos, Git) first. Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Minimal" in Q4. @@ -96,6 +97,7 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min - "Config counts" - 2 CLAUDE.md | 4 rules - "Token breakdown" - (in: 45k, cache: 12k) - "Usage limits" - 5h: 25% | 7d: 10% + - "Usage bar style" - ██░░ 25% visual bar (only if usageBarEnabled is false) - "Session duration" - ⏱️ 5m ### Q3: Git Style (only if Git is currently enabled) @@ -108,7 +110,7 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min - "Full details" - git:(main* ↑2 ↓1) includes ahead/behind - "File stats" - git:(main* !2 +1 ?3) Starship-compatible format -**Skip Q3 if Git is OFF** - show only 3 questions total, or replace with placeholder. +**Skip Q3 if Git is OFF** - proceed to Q4. ### Q4: Layout/Reset - header: "Layout/Reset" @@ -174,6 +176,7 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min | Config counts | `display.showConfigCounts` | | Token breakdown | `display.showTokenBreakdown` | | Usage limits | `display.showUsage` | +| Usage bar style | `display.usageBarEnabled` | | Session duration | `display.showDuration` | **Always true (not configurable):** @@ -182,6 +185,17 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min --- +## Usage Style Mapping + +| Option | Config | +|--------|--------| +| Bar style | `display.usageBarEnabled: true` — Shows `██░░ 25% (1h 30m / 5h)` | +| Text style | `display.usageBarEnabled: false` — Shows `5h: 25% (1h 30m)` | + +**Note**: Usage style only applies when `display.showUsage: true`. When 7d usage >= 80%, it also shows with the same style. + +--- + ## Processing Logic ### For New Users (Flow A): @@ -192,8 +206,8 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min ### For Returning Users (Flow B): 1. Start from current config -2. Apply Turn Off selections (set to OFF) -3. Apply Turn On selections (set to ON) +2. Apply Turn Off selections (set to OFF, including usageBarEnabled if selected) +3. Apply Turn On selections (set to ON, including usageBarEnabled if selected) 4. Apply Git Style selection (if shown) 5. If "Reset to [preset]" selected, override with preset values 6. If layout change selected, apply it diff --git a/src/config.ts b/src/config.ts index 75f1b1b..f01db69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface HudConfig { showDuration: boolean; showTokenBreakdown: boolean; showUsage: boolean; + usageBarEnabled: boolean; showTools: boolean; showAgents: boolean; showTodos: boolean; @@ -49,6 +50,7 @@ export const DEFAULT_CONFIG: HudConfig = { showDuration: true, showTokenBreakdown: true, showUsage: true, + usageBarEnabled: true, showTools: true, showAgents: true, showTodos: true, @@ -150,6 +152,9 @@ function mergeConfig(userConfig: Partial): HudConfig { showUsage: typeof migrated.display?.showUsage === 'boolean' ? migrated.display.showUsage : DEFAULT_CONFIG.display.showUsage, + usageBarEnabled: typeof migrated.display?.usageBarEnabled === 'boolean' + ? migrated.display.usageBarEnabled + : DEFAULT_CONFIG.display.usageBarEnabled, showTools: typeof migrated.display?.showTools === 'boolean' ? migrated.display.showTools : DEFAULT_CONFIG.display.showTools, diff --git a/src/render/colors.ts b/src/render/colors.ts index 9bfc1be..c4593f4 100644 --- a/src/render/colors.ts +++ b/src/render/colors.ts @@ -6,6 +6,8 @@ const GREEN = '\x1b[32m'; const YELLOW = '\x1b[33m'; const MAGENTA = '\x1b[35m'; const CYAN = '\x1b[36m'; +const BRIGHT_BLUE = '\x1b[94m'; +const BRIGHT_MAGENTA = '\x1b[95m'; export function green(text: string): string { return `${GREEN}${text}${RESET}`; @@ -37,9 +39,26 @@ export function getContextColor(percent: number): string { return GREEN; } -export function coloredBar(percent: number, width: number = 10): string { - const filled = Math.round((percent / 100) * width); - const empty = width - filled; - const color = getContextColor(percent); +export function getQuotaColor(percent: number): string { + if (percent >= 90) return RED; + if (percent >= 75) return BRIGHT_MAGENTA; + return BRIGHT_BLUE; +} + +export function quotaBar(percent: number, width: number = 10): string { + const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0; + const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0; + const filled = Math.round((safePercent / 100) * safeWidth); + const empty = safeWidth - filled; + const color = getQuotaColor(safePercent); + return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`; +} + +export function coloredBar(percent: number, width: number = 10): string { + const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0; + const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0; + const filled = Math.round((safePercent / 100) * safeWidth); + const empty = safeWidth - filled; + const color = getContextColor(safePercent); return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`; } diff --git a/src/render/index.ts b/src/render/index.ts index 30a8b0d..0fa4735 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -77,9 +77,14 @@ function renderExpanded(ctx: RenderContext): string[] { lines.push(environmentLine); } - const usageLine = renderUsageLine(ctx); - if (usageLine) { - lines.push(usageLine); + // Only show separate usage line when usageBarEnabled is false + // When true, usage is rendered inline with identity line + const usageBarEnabled = ctx.config?.display?.usageBarEnabled ?? true; + if (!usageBarEnabled) { + const usageLine = renderUsageLine(ctx); + if (usageLine) { + lines.push(usageLine); + } } return lines; diff --git a/src/render/lines/identity.ts b/src/render/lines/identity.ts index 3ee4ce7..bf1785b 100644 --- a/src/render/lines/identity.ts +++ b/src/render/lines/identity.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../../types.js'; +import { isLimitReached } from '../../types.js'; import { getContextPercent, getBufferedPercent, getModelName } from '../../stdin.js'; -import { coloredBar, cyan, dim, getContextColor, RESET } from '../colors.js'; +import { coloredBar, cyan, dim, red, yellow, getContextColor, quotaBar, RESET } from '../colors.js'; const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; @@ -33,6 +34,15 @@ export function renderIdentityLine(ctx: RenderContext): string { parts.push(`${getContextColor(percent)}${percent}%${RESET}`); } + // Inline usage bar (only when usageBarEnabled is true in expanded mode) + const usageBarEnabled = display?.usageBarEnabled ?? true; + if (usageBarEnabled && display?.showUsage !== false && ctx.usageData?.planName) { + const usagePart = renderInlineUsage(ctx); + if (usagePart) { + parts.push(usagePart); + } + } + if (display?.showDuration !== false && ctx.sessionDuration) { parts.push(dim(`⏱️ ${ctx.sessionDuration}`)); } @@ -60,3 +70,69 @@ function formatTokens(n: number): string { } return n.toString(); } + +function renderInlineUsage(ctx: RenderContext): string | null { + if (!ctx.usageData?.planName) { + return null; + } + + if (ctx.usageData.apiUnavailable) { + return yellow(`⚠`); + } + + if (isLimitReached(ctx.usageData)) { + const resetTime = ctx.usageData.fiveHour === 100 + ? formatResetTime(ctx.usageData.fiveHourResetAt) + : formatResetTime(ctx.usageData.sevenDayResetAt); + return red(`⚠ Limit${resetTime ? ` (${resetTime})` : ''}`); + } + + const display = ctx.config?.display; + const threshold = display?.usageThreshold ?? 0; + const fiveHour = ctx.usageData.fiveHour; + const sevenDay = ctx.usageData.sevenDay; + + const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0); + if (effectiveUsage < threshold) { + return null; + } + + const fiveHourDisplay = formatUsagePercent(fiveHour); + const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt); + const fiveHourPart = fiveHourReset + ? `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay} (${fiveHourReset} / 5h)` + : `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay}`; + + if (sevenDay !== null && sevenDay >= 80) { + const sevenDayDisplay = formatUsagePercent(sevenDay); + const sevenDayReset = formatResetTime(ctx.usageData.sevenDayResetAt); + const sevenDayPart = sevenDayReset + ? `${quotaBar(sevenDay)} ${sevenDayDisplay} (${sevenDayReset} / 7d)` + : `${quotaBar(sevenDay)} ${sevenDayDisplay}`; + return `${fiveHourPart} | ${sevenDayPart}`; + } + + return fiveHourPart; +} + +function formatUsagePercent(percent: number | null): string { + if (percent === null) { + return dim('--'); + } + const color = getContextColor(percent); + return `${color}${percent}%${RESET}`; +} + +function formatResetTime(resetAt: Date | null): string { + if (!resetAt) return ''; + const now = new Date(); + const diffMs = resetAt.getTime() - now.getTime(); + if (diffMs <= 0) return ''; + + const diffMins = Math.ceil(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} diff --git a/src/render/lines/usage.ts b/src/render/lines/usage.ts index 5d627fb..3521909 100644 --- a/src/render/lines/usage.ts +++ b/src/render/lines/usage.ts @@ -1,6 +1,6 @@ import type { RenderContext } from '../../types.js'; import { isLimitReached } from '../../types.js'; -import { red, yellow, dim, getContextColor, RESET } from '../colors.js'; +import { red, yellow, dim, getContextColor, quotaBar, RESET } from '../colors.js'; export function renderUsageLine(ctx: RenderContext): string | null { const display = ctx.config?.display; @@ -35,13 +35,25 @@ export function renderUsageLine(ctx: RenderContext): string | null { const fiveHourDisplay = formatUsagePercent(ctx.usageData.fiveHour); const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt); - const fiveHourPart = fiveHourReset - ? `5h: ${fiveHourDisplay} (${fiveHourReset})` - : `5h: ${fiveHourDisplay}`; + + const usageBarEnabled = display?.usageBarEnabled ?? true; + const fiveHourPart = usageBarEnabled + ? (fiveHourReset + ? `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay} (${fiveHourReset} / 5h)` + : `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay}`) + : (fiveHourReset + ? `5h: ${fiveHourDisplay} (${fiveHourReset})` + : `5h: ${fiveHourDisplay}`); if (sevenDay !== null && sevenDay >= 80) { const sevenDayDisplay = formatUsagePercent(sevenDay); - return `${fiveHourPart} | 7d: ${sevenDayDisplay}`; + const sevenDayReset = formatResetTime(ctx.usageData.sevenDayResetAt); + const sevenDayPart = usageBarEnabled + ? (sevenDayReset + ? `${quotaBar(sevenDay)} ${sevenDayDisplay} (${sevenDayReset} / 7d)` + : `${quotaBar(sevenDay)} ${sevenDayDisplay}`) + : `7d: ${sevenDayDisplay}`; + return `${fiveHourPart} | ${sevenDayPart}`; } return fiveHourPart; diff --git a/src/render/session-line.ts b/src/render/session-line.ts index e23d91d..bc80e78 100644 --- a/src/render/session-line.ts +++ b/src/render/session-line.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types.js'; import { isLimitReached } from '../types.js'; import { getContextPercent, getBufferedPercent, getModelName } from '../stdin.js'; -import { coloredBar, cyan, dim, magenta, red, yellow, getContextColor, RESET } from './colors.js'; +import { coloredBar, cyan, dim, magenta, red, yellow, getContextColor, quotaBar, RESET } from './colors.js'; const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; @@ -134,13 +134,25 @@ export function renderSessionLine(ctx: RenderContext): string { if (effectiveUsage >= usageThreshold) { const fiveHourDisplay = formatUsagePercent(fiveHour); const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt); - const fiveHourPart = fiveHourReset - ? `5h: ${fiveHourDisplay} (${fiveHourReset})` - : `5h: ${fiveHourDisplay}`; + + const usageBarEnabled = display?.usageBarEnabled ?? true; + const fiveHourPart = usageBarEnabled + ? (fiveHourReset + ? `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay} (${fiveHourReset} / 5h)` + : `${quotaBar(fiveHour ?? 0)} ${fiveHourDisplay}`) + : (fiveHourReset + ? `5h: ${fiveHourDisplay} (${fiveHourReset})` + : `5h: ${fiveHourDisplay}`); if (sevenDay !== null && sevenDay >= 80) { const sevenDayDisplay = formatUsagePercent(sevenDay); - parts.push(`${fiveHourPart} | 7d: ${sevenDayDisplay}`); + const sevenDayReset = formatResetTime(ctx.usageData.sevenDayResetAt); + const sevenDayPart = usageBarEnabled + ? (sevenDayReset + ? `${quotaBar(sevenDay)} ${sevenDayDisplay} (${sevenDayReset} / 7d)` + : `${quotaBar(sevenDay)} ${sevenDayDisplay}`) + : `7d: ${sevenDayDisplay}`; + parts.push(`${fiveHourPart} | ${sevenDayPart}`); } else { parts.push(fiveHourPart); } diff --git a/tests/render.test.js b/tests/render.test.js index 181b3af..91dc1d6 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -33,7 +33,7 @@ function baseContext() { showSeparators: false, pathLevels: 1, gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false }, - display: { showModel: true, showContextBar: true, showConfigCounts: true, showDuration: true, showTokenBreakdown: true, showUsage: true, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, environmentThreshold: 0 }, + display: { showModel: true, showContextBar: true, showConfigCounts: true, showDuration: true, showTokenBreakdown: true, showUsage: true, usageBarEnabled: false, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, environmentThreshold: 0 }, }, }; }