diff --git a/README.md b/README.md index c71390a..b29dd14 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,22 @@ An [opencode](https://opencode.ai) TUI sidebar plugin that displays your Claude account usage. Shows session and weekly rate limits with reset countdowns. +**Text mode** (default): ``` -Claude Usage -juneyoung.kang@wantedlab.com -via cli -Session 31% resets in 3h 16m -Weekly 11% resets in 4d 5h +▼ Claude Usage + juneyoung.kang@wantedlab.com + via cli + Session 31% resets in 3h 16m + Weekly 11% resets in 4d 5h +``` + +**Bar mode** (`"displayMode": "bar"`): +``` +▼ Claude Usage + juneyoung.kang@wantedlab.com + via cli + Session █████░░░░░░░░░ 31% (3h 16m) + Weekly ██░░░░░░░░░░░░ 11% (4d 5h) ``` ## Install @@ -41,6 +51,7 @@ opencode resolves the npm package on startup automatically. "plugin": [["opencode-claude-usage", { "enabled": true, "refreshInterval": 60, + "displayMode": "text", "headerColor": "#E07A3A", "valueColor": "#82AAFF", "dimColor": "#546E7A" @@ -51,6 +62,7 @@ opencode resolves the npm package on startup automatically. | Option | Default | Description | |---|---|---| | `refreshInterval` | `60` | Seconds between data refreshes | +| `displayMode` | `"text"` | `"text"` shows percentage + reset time, `"bar"` shows progress bar + percentage + reset time | | `headerColor` | theme text | Color of window labels (Session, Weekly, etc.) | | `valueColor` | `#82AAFF` | Color of percentage values | | `dimColor` | theme muted | Color of reset times and secondary text | diff --git a/src/format.ts b/src/format.ts index 9ab61e4..4f04bef 100644 --- a/src/format.ts +++ b/src/format.ts @@ -167,6 +167,29 @@ export function formatCost( return `${symbol}${used} / ${symbol}${limit}` } +/** + * Build a progress bar string of the given width. + * Returns { filled, empty } strings for separate coloring in the TUI. + */ +const BAR_WIDTH = 14 +const FILLED_CHAR = "█" +const EMPTY_CHAR = "░" + +export function formatBar( + utilization: number | null | undefined, + width: number = BAR_WIDTH, +): { filled: string; empty: string } { + if (utilization === null || utilization === undefined) { + return { filled: "", empty: EMPTY_CHAR.repeat(width) } + } + const clamped = Math.max(0, Math.min(100, utilization)) + const filledCount = Math.round((clamped / 100) * width) + return { + filled: FILLED_CHAR.repeat(filledCount), + empty: EMPTY_CHAR.repeat(width - filledCount), + } +} + /** * Map an OAuthUsageResponse field key to a short display label. */ diff --git a/src/tui.tsx b/src/tui.tsx index 7d2371a..6d8e39a 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,7 +3,7 @@ import { createSignal } from "solid-js" import type { TuiPlugin, TuiPluginModule, TuiSlotContext } from "@opencode-ai/plugin/tui" import type { UsageState, PluginOptions } from "./types" import { createRefreshLoop } from "./fetcher" -import { formatRelativeTime, formatPercentage, formatCost, windowLabel } from "./format" +import { formatRelativeTime, formatPercentage, formatBar, formatCost, windowLabel } from "./format" const CLAUDE_ORANGE = "#E07A3A" @@ -24,6 +24,7 @@ const DEFAULT_REFRESH_INTERVAL_S = 60 const tui: TuiPlugin = async (api, rawOptions, _meta) => { const options = (rawOptions as PluginOptions | undefined) ?? {} const refreshIntervalMs = (options.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_S) * 1000 + const displayMode = options.displayMode ?? "text" const [state, setState] = createSignal({ status: "idle", @@ -32,6 +33,7 @@ const tui: TuiPlugin = async (api, rawOptions, _meta) => { authMethod: "none", error: null, }) + const [open, setOpen] = createSignal(true) const EXPECTED_LOAD_S = 25 const [countdown, setCountdown] = createSignal(EXPECTED_LOAD_S) @@ -111,50 +113,73 @@ const tui: TuiPlugin = async (api, rawOptions, _meta) => { const data = s.data const profile = s.profile + const isOpen = open() return ( - {"Claude Usage"} - {profile?.email ? ( - - {profile.email} - - ) : null} - - {profile?.email ? ( - - {`via ${s.authMethod}`} - - ) : null} + setOpen(!open())}> + + {isOpen ? "\u25BC" : "\u25B6"}{" Claude Usage"} + + - {data ? ( + {isOpen ? ( - {WINDOW_KEYS.map((key) => { - const w = data[key as WindowKey] - if (!w) return null - const pct = w.utilization - const reset = w.resetsAt - const label = windowLabel(key) - const pctColor = pct === null ? valueFg - : pct >= 80 ? CLAUDE_ORANGE - : pct >= 51 ? "#F0A875" - : valueFg - const resetStr = formatRelativeTime(reset) - return ( - - {label.padEnd(10)} - {formatPercentage(pct).padStart(5)} - {` resets in ${resetStr}`} - - ) - })} - - {data.extraUsage?.isEnabled ? ( - - {"Credit "} - - {formatCost(data.extraUsage.usedCredits, data.extraUsage.monthlyLimit, data.extraUsage.currency)} - + {profile?.email ? ( + + {` ${profile.email}`} + + ) : null} + + {profile?.email ? ( + + {` via ${s.authMethod}`} + + ) : null} + + {data ? ( + + {WINDOW_KEYS.map((key) => { + const w = data[key as WindowKey] + if (!w) return null + const pct = w.utilization + const label = windowLabel(key) + const pctColor = pct === null ? valueFg + : pct >= 80 ? CLAUDE_ORANGE + : pct >= 51 ? "#F0A875" + : valueFg + + if (displayMode === "bar") { + const bar = formatBar(pct) + const resetStr = formatRelativeTime(w.resetsAt) + const resetSuffix = resetStr && resetStr !== "—" ? ` (${resetStr})` : "" + return ( + + {` ${label.padEnd(8)}`} + {bar.filled + bar.empty + formatPercentage(pct).padStart(4)} + {resetSuffix} + + ) + } + + const resetStr = formatRelativeTime(w.resetsAt) + return ( + + {` ${label.padEnd(9)}`} + {formatPercentage(pct).padStart(5)} + {` resets in ${resetStr}`} + + ) + })} + + {data.extraUsage?.isEnabled ? ( + + {"Credit "} + + {formatCost(data.extraUsage.usedCredits, data.extraUsage.monthlyLimit, data.extraUsage.currency)} + + + ) : null} ) : null} diff --git a/src/types.ts b/src/types.ts index 9c278fc..a831d2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,8 +77,11 @@ export interface UsageState { } // Plugin configuration options (from tui.json) +export type DisplayMode = "text" | "bar" + export interface PluginOptions { refreshInterval?: number + displayMode?: DisplayMode headerColor?: string valueColor?: string dimColor?: string