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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
105 changes: 65 additions & 40 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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<UsageState>({
status: "idle",
Expand All @@ -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)
Expand Down Expand Up @@ -111,50 +113,73 @@ const tui: TuiPlugin = async (api, rawOptions, _meta) => {

const data = s.data
const profile = s.profile
const isOpen = open()

return (
<box flexDirection="column">
<box height={1}><text fg={CLAUDE_ORANGE}><b>{"Claude Usage"}</b></text></box>
{profile?.email ? (
<box height={1}>
<text fg={dim}>{profile.email}</text>
</box>
) : null}

{profile?.email ? (
<box height={1}>
<text fg={dim}>{`via ${s.authMethod}`}</text>
</box>
) : null}
<box height={1} flexDirection="row" onMouseDown={() => setOpen(!open())}>
<text fg={CLAUDE_ORANGE}>
<b>{isOpen ? "\u25BC" : "\u25B6"}{" Claude Usage"}</b>
</text>
</box>

{data ? (
{isOpen ? (
<box flexDirection="column">
{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 (
<box height={1} flexDirection="row">
<text fg={fg}>{label.padEnd(10)}</text>
<text fg={pctColor}>{formatPercentage(pct).padStart(5)}</text>
<text fg={dim}>{` resets in ${resetStr}`}</text>
</box>
)
})}

{data.extraUsage?.isEnabled ? (
<box height={1} flexDirection="row">
<text fg={fg}>{"Credit "}</text>
<text fg={valueFg}>
{formatCost(data.extraUsage.usedCredits, data.extraUsage.monthlyLimit, data.extraUsage.currency)}
</text>
{profile?.email ? (
<box height={1}>
<text fg={dim}>{` ${profile.email}`}</text>
</box>
) : null}

{profile?.email ? (
<box height={1}>
<text fg={dim}>{` via ${s.authMethod}`}</text>
</box>
) : null}

{data ? (
<box flexDirection="column">
{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 (
<box height={1} flexDirection="row">
<text fg={fg}>{` ${label.padEnd(8)}`}</text>
<text fg={pctColor}>{bar.filled + bar.empty + formatPercentage(pct).padStart(4)}</text>
<text fg={dim}>{resetSuffix}</text>
</box>
)
}

const resetStr = formatRelativeTime(w.resetsAt)
return (
<box height={1} flexDirection="row">
<text fg={fg}>{` ${label.padEnd(9)}`}</text>
<text fg={pctColor}>{formatPercentage(pct).padStart(5)}</text>
<text fg={dim}>{` resets in ${resetStr}`}</text>
</box>
)
})}

{data.extraUsage?.isEnabled ? (
<box height={1} flexDirection="row">
<text fg={fg}>{"Credit "}</text>
<text fg={valueFg}>
{formatCost(data.extraUsage.usedCredits, data.extraUsage.monthlyLimit, data.extraUsage.currency)}
</text>
</box>
) : null}
</box>
) : null}
</box>
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading