Skip to content

Commit ce6605e

Browse files
author
OpenCode Agent
committed
feat: add usage tracking API and /usage dialog
This update aligns Copilot usage tracking with the native OpenCode Copilot plugin by utilizing real-time rate-limit headers and token metadata rather than flaky internal API calls.
1 parent 25cb03d commit ce6605e

11 files changed

Lines changed: 1528 additions & 33 deletions

File tree

.opencode/remember.jsonc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
// Enable or disable the plugin
3+
"enabled": true,
4+
// Where to store memories: "global", "project", or "both"
5+
// - "global": ~/.config/opencode/memory/memories.sqlite (shared across projects)
6+
// - "project": .opencode/memory/memories.sqlite (project-specific)
7+
// - "both": search both, save to project
8+
"scope": "project",
9+
// Memory injection settings
10+
"inject": {
11+
// Number of memories to inject after user messages (default: 5)
12+
"count": 5,
13+
// Score threshold for [important] vs [related] tag (default: 0.6)
14+
"highThreshold": 0.6
15+
}
16+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import { useTheme } from "../context/theme"
3+
import { For, Show } from "solid-js"
4+
5+
type Theme = ReturnType<typeof useTheme>["theme"]
6+
7+
type UsageWindow = {
8+
usedPercent: number
9+
windowMinutes: number | null
10+
resetsAt: number | null
11+
}
12+
13+
export type UsageEntry = {
14+
provider: string
15+
displayName: string
16+
snapshot: {
17+
primary: UsageWindow | null
18+
secondary: UsageWindow | null
19+
credits: {
20+
hasCredits: boolean
21+
unlimited: boolean
22+
balance: string | null
23+
} | null
24+
planType: string | null
25+
updatedAt: number
26+
}
27+
}
28+
29+
export function DialogUsage(props: { entries: UsageEntry[] }) {
30+
const { theme } = useTheme()
31+
32+
return (
33+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1} flexDirection="column">
34+
<box flexDirection="row" justifyContent="space-between">
35+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
36+
Usage
37+
</text>
38+
<text fg={theme.textMuted}>esc</text>
39+
</box>
40+
<Show when={props.entries.length > 0} fallback={<text fg={theme.text}>No usage data available.</text>}>
41+
<For each={props.entries}>
42+
{(entry, index) => {
43+
const mergeReset = entry.provider === "copilot"
44+
const resetAt = entry.snapshot.primary?.resetsAt ?? entry.snapshot.secondary?.resetsAt ?? null
45+
return (
46+
<box flexDirection="column" marginTop={index() === 0 ? 0 : 1} gap={1}>
47+
<box flexDirection="column">
48+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
49+
{entry.displayName} Usage ({formatPlanType(entry.snapshot.planType)} Plan)
50+
</text>
51+
<text fg={theme.textMuted}>{"─".repeat(Math.max(24, entry.displayName.length + 20))}</text>
52+
</box>
53+
<Show when={entry.snapshot.primary}>
54+
{(window) => (
55+
<box flexDirection="column">
56+
{renderWindow(getWindowLabel(entry.provider, "primary"), window(), theme, !mergeReset)}
57+
</box>
58+
)}
59+
</Show>
60+
<Show when={entry.snapshot.secondary}>
61+
{(window) => (
62+
<box flexDirection="column">
63+
{renderWindow(getWindowLabel(entry.provider, "secondary"), window(), theme, !mergeReset)}
64+
</box>
65+
)}
66+
</Show>
67+
<Show when={mergeReset && resetAt !== null}>
68+
<text fg={theme.textMuted}>Resets {formatResetTime(resetAt!)}</text>
69+
</Show>
70+
<Show when={entry.snapshot.credits}>
71+
{(credits) => <text fg={theme.text}>{formatCreditsLabel(entry.provider, credits())}</text>}
72+
</Show>
73+
</box>
74+
)
75+
}}
76+
</For>
77+
</Show>
78+
</box>
79+
)
80+
}
81+
82+
function getWindowLabel(provider: string, windowType: "primary" | "secondary"): string {
83+
if (provider === "copilot") {
84+
return windowType === "primary" ? "Usage" : "Completions"
85+
}
86+
return windowType === "primary" ? "Hourly" : "Weekly"
87+
}
88+
89+
function renderWindow(label: string, window: UsageWindow, theme: Theme, showReset = true) {
90+
const usedPercent = clampPercent(window.usedPercent)
91+
const bar = renderProgressBar(usedPercent)
92+
const windowLabel = formatWindowLabel(label, window.windowMinutes)
93+
94+
return (
95+
<box flexDirection="column">
96+
<text fg={theme.text}>
97+
{windowLabel} Limit: {bar} {usedPercent.toFixed(0)}% used
98+
</text>
99+
<Show when={showReset && window.resetsAt !== null}>
100+
<text fg={theme.textMuted}>Resets {formatResetTime(window.resetsAt!)}</text>
101+
</Show>
102+
</box>
103+
)
104+
}
105+
106+
function formatWindowLabel(base: string, windowMinutes: number | null): string {
107+
if (!windowMinutes) return base
108+
const minutesPerHour = 60
109+
const minutesPerDay = 24 * minutesPerHour
110+
if (windowMinutes <= minutesPerDay) {
111+
const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour))
112+
if (hours === 1) return "Hourly"
113+
return `${hours}h`
114+
}
115+
return base
116+
}
117+
118+
function formatResetTime(resetAt: number): string {
119+
const now = Math.floor(Date.now() / 1000)
120+
const diff = resetAt - now
121+
if (diff <= 0) return "now"
122+
if (diff < 60) return `in ${diff} seconds`
123+
if (diff < 3600) return `in ${Math.round(diff / 60)} minutes`
124+
if (diff < 86400) return `in ${Math.round(diff / 3600)} hours`
125+
return `in ${Math.round(diff / 86400)} days`
126+
}
127+
128+
function renderProgressBar(usedPercent: number, width = 10): string {
129+
const filled = Math.round((usedPercent / 100) * width)
130+
const empty = width - filled
131+
return `[${"█".repeat(filled)}${"░".repeat(empty)}]`
132+
}
133+
134+
function formatPlanType(planType: string | null): string {
135+
if (!planType) return "Unknown"
136+
const normalized = planType.replace(/_/g, " ")
137+
const parts: string[] = []
138+
for (const part of normalized.split(" ")) {
139+
if (!part) continue
140+
parts.push(part.slice(0, 1).toUpperCase() + part.slice(1))
141+
}
142+
return parts.join(" ")
143+
}
144+
145+
function formatCreditsLabel(
146+
provider: string,
147+
credits: { hasCredits: boolean; unlimited: boolean; balance: string | null },
148+
): string {
149+
if (provider === "copilot") {
150+
if (credits.unlimited) return "Quota: Unlimited"
151+
if (credits.balance) return `Quota: ${credits.balance}`
152+
if (!credits.hasCredits) return "Quota: Exhausted"
153+
return "Quota: Available"
154+
}
155+
return `Credits: ${formatCredits(credits)}`
156+
}
157+
158+
function formatCredits(credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }): string {
159+
if (!credits.hasCredits) return "None"
160+
if (credits.unlimited) return "Unlimited"
161+
if (credits.balance) return credits.balance
162+
return "Available"
163+
}
164+
165+
function clampPercent(value: number): number {
166+
if (Number.isNaN(value)) return 0
167+
if (value < 0) return 0
168+
if (value > 100) return 100
169+
return value
170+
}

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function Autocomplete(props: {
7373
fileStyleId: number
7474
agentStyleId: number
7575
promptPartTypeId: () => number
76+
onUsage: (command: string) => void
7677
}) {
7778
const sdk = useSDK()
7879
const sync = useSync()
@@ -444,6 +445,11 @@ export function Autocomplete(props: {
444445
description: "show status",
445446
onSelect: () => command.trigger("opencode.status"),
446447
},
448+
{
449+
display: "/usage",
450+
description: "show usage limits",
451+
onSelect: () => props.onUsage("/usage"),
452+
},
447453
{
448454
display: "/mcp",
449455
description: "toggle MCPs",

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
3030
import { DialogAlert } from "../../ui/dialog-alert"
3131
import { useToast } from "../../ui/toast"
3232
import { useKV } from "../../context/kv"
33+
import { DialogUsage, type UsageEntry } from "../dialog-usage"
3334
import { useTextareaKeybindings } from "../textarea-keybindings"
3435

3536
export type PromptProps = {
@@ -74,6 +75,34 @@ export function Prompt(props: PromptProps) {
7475
const { theme, syntax } = useTheme()
7576
const kv = useKV()
7677

78+
function handleUsageCommand(commandText: string) {
79+
const parts = commandText.trim().split(/\s+/)
80+
const provider = parts.length > 1 && !parts[1].startsWith("-") ? parts[1] : undefined
81+
const refresh = parts.some((part) => part === "--refresh" || part === "-r")
82+
83+
type UsageResponse = {
84+
entries: UsageEntry[]
85+
error?: string
86+
}
87+
88+
sdk.client.usage
89+
.get({ provider, refresh })
90+
.then((res) => {
91+
const data = res.data as UsageResponse | undefined
92+
if (!data) return
93+
if (data.entries.length > 0) {
94+
dialog.replace(() => <DialogUsage entries={data.entries} />)
95+
return
96+
}
97+
const message = data.error ?? "No usage data available."
98+
DialogAlert.show(dialog, "Usage", message)
99+
})
100+
.catch((error: unknown) => {
101+
const message = error instanceof Error ? error.message : String(error)
102+
DialogAlert.show(dialog, "Usage", message)
103+
})
104+
}
105+
77106
function promptModelWarning() {
78107
toast.show({
79108
variant: "warning",
@@ -484,7 +513,7 @@ export function Prompt(props: PromptProps) {
484513

485514
async function submit() {
486515
if (props.disabled) return
487-
if (autocomplete?.visible) return
516+
if (autocomplete?.visible && !store.prompt.input.startsWith("/usage")) return
488517
if (!store.prompt.input) return
489518
const trimmed = store.prompt.input.trim()
490519
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
@@ -528,7 +557,16 @@ export function Prompt(props: PromptProps) {
528557
const currentMode = store.mode
529558
const variant = local.model.variant.current()
530559

531-
if (store.mode === "shell") {
560+
const isShell = store.mode === "shell"
561+
const isUsage = inputText.startsWith("/usage")
562+
const isCommand =
563+
inputText.startsWith("/") &&
564+
iife(() => {
565+
const command = inputText.split(" ")[0].slice(1)
566+
return sync.data.command.some((x) => x.name === command)
567+
})
568+
569+
if (isShell) {
532570
sdk.client.session.shell({
533571
sessionID,
534572
agent: local.agent.current().name,
@@ -539,15 +577,23 @@ export function Prompt(props: PromptProps) {
539577
command: inputText,
540578
})
541579
setStore("mode", "normal")
542-
} else if (
543-
inputText.startsWith("/") &&
544-
iife(() => {
545-
const command = inputText.split(" ")[0].slice(1)
546-
console.log(command)
547-
return sync.data.command.some((x) => x.name === command)
580+
}
581+
582+
if (isUsage) {
583+
handleUsageCommand(inputText)
584+
input.extmarks.clear()
585+
setStore("prompt", {
586+
input: "",
587+
parts: [],
548588
})
549-
) {
550-
let [command, ...args] = inputText.split(" ")
589+
setStore("extmarkToPartIndex", new Map())
590+
props.onSubmit?.()
591+
input.clear()
592+
return
593+
}
594+
595+
if (isCommand) {
596+
const [command, ...args] = inputText.split(" ")
551597
sdk.client.session.command({
552598
sessionID,
553599
command: command.slice(1),
@@ -563,29 +609,30 @@ export function Prompt(props: PromptProps) {
563609
...x,
564610
})),
565611
})
566-
} else {
567-
sdk.client.session
568-
.prompt({
569-
sessionID,
570-
...selectedModel,
571-
messageID,
572-
agent: local.agent.current().name,
573-
model: selectedModel,
574-
variant,
575-
parts: [
576-
{
577-
id: Identifier.ascending("part"),
578-
type: "text",
579-
text: inputText,
580-
},
581-
...nonTextParts.map((x) => ({
582-
id: Identifier.ascending("part"),
583-
...x,
584-
})),
585-
],
586-
})
587-
.catch(() => {})
588612
}
613+
614+
if (!isShell && !isUsage && !isCommand) {
615+
sdk.client.session.prompt({
616+
sessionID,
617+
...selectedModel,
618+
messageID,
619+
agent: local.agent.current().name,
620+
model: selectedModel,
621+
variant,
622+
parts: [
623+
{
624+
id: Identifier.ascending("part"),
625+
type: "text",
626+
text: inputText,
627+
},
628+
...nonTextParts.map((x) => ({
629+
id: Identifier.ascending("part"),
630+
...x,
631+
})),
632+
],
633+
})
634+
}
635+
589636
history.append({
590637
...store.prompt,
591638
mode: currentMode,
@@ -741,6 +788,7 @@ export function Prompt(props: PromptProps) {
741788
fileStyleId={fileStyleId}
742789
agentStyleId={agentStyleId}
743790
promptPartTypeId={() => promptPartTypeId}
791+
onUsage={handleUsageCommand}
744792
/>
745793
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
746794
<box

0 commit comments

Comments
 (0)