Skip to content

Commit 5e4aa55

Browse files
authored
feat(tui): show OpenCode Console usage limits (#58731)
1 parent e42f6d1 commit 5e4aa55

16 files changed

Lines changed: 948 additions & 30 deletions

File tree

‎packages/client/src/solid/data.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type {
3434
SessionInboxCompaction,
3535
ShellInfo,
3636
SkillInfo,
37+
UsageLimitsInfo,
3738
VcsInfo,
3839
OpenCodeEvent,
3940
OpenCodeClient,
@@ -103,6 +104,9 @@ type LocationData = {
103104
provider?: ProviderInfo[]
104105
reference?: ReferenceInfo[]
105106
websearch?: WebSearchProvider[]
107+
// The Go allowance and Console budgets of the OpenCode Console workspace the location is signed in to; null when it
108+
// isn't signed in.
109+
usageLimits?: UsageLimitsInfo | null
106110
// Currently running shell commands for this location, keyed by shell id. Entries are removed
107111
// once the command exits or is deleted, so this only ever holds in-flight shells.
108112
shell?: Record<string, ShellWithLocation>
@@ -942,6 +946,12 @@ export function createData(config: CreateDataInput) {
942946
return
943947
}
944948
case "session.step.failed":
949+
// A budget refusal means the cached usage limits are out of date, which is what the refusal sends people to.
950+
if (
951+
event.location &&
952+
(event.data.error.type === "provider.budget-limit" || event.data.error.type === "provider.model-budget-limit")
953+
)
954+
refreshUsageLimits(event.location)
945955
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
946956
assistant.time.completed = event.created
947957
assistant.finish = event.data.finish ?? "error"
@@ -1227,6 +1237,7 @@ export function createData(config: CreateDataInput) {
12271237
Object.keys(store.location).forEach((key) => {
12281238
const ref = JSON.parse(key) as [string, string | null]
12291239
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
1240+
refreshUsageLimits(location)
12301241
if (event.type === "credential.updated") {
12311242
result.location.integration.invalidate(location)
12321243
refresh(() => result.location.integration.sync(location))
@@ -1311,6 +1322,7 @@ export function createData(config: CreateDataInput) {
13111322
refresh(() => result.location.reference.sync(location))
13121323
break
13131324
case "integration.updated":
1325+
refreshUsageLimits(location)
13141326
result.location.integration.invalidate(location)
13151327
result.location.model.invalidate(location)
13161328
result.location.provider.invalidate(location)
@@ -1348,6 +1360,15 @@ export function createData(config: CreateDataInput) {
13481360
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
13491361
// effective location, and publishes under the server's canonical location; `alias`
13501362
// also publishes under the requested key when the two differ.
1363+
// Reloads usage limits only where something has read them, since each load asks OpenCode Console.
1364+
function refreshUsageLimits(location: LocationRef) {
1365+
const loaded =
1366+
store.location[locationKey(location)]?.usageLimits !== undefined ||
1367+
sync.has(`location.usageLimits:${locationKey(location)}`)
1368+
usageLimits.invalidate(location)
1369+
if (loaded) refresh(() => usageLimits.sync(location))
1370+
}
1371+
13511372
function locationResource<Field extends keyof LocationData>(
13521373
field: Field,
13531374
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
@@ -1371,6 +1392,7 @@ export function createData(config: CreateDataInput) {
13711392
}
13721393

13731394
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
1395+
const usageLimits = locationResource("usageLimits", (location) => api().usage.limits({ location }))
13741396
const shells = locationResource("shell", async (location) => {
13751397
const response = await api().shell.list({ location })
13761398
const ref = { directory: response.location.directory }
@@ -1982,6 +2004,7 @@ export function createData(config: CreateDataInput) {
19822004
result.session.form.invalidate("global", location)
19832005
},
19842006
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
2007+
usage: { limits: usageLimits.list, sync: usageLimits.sync, invalidate: usageLimits.invalidate },
19852008
agent: locationResource("agent", (location) => api().agent.list({ location })),
19862009
command: locationResource("command", (location) => api().command.list({ location })),
19872010
config: locationResource("config", async (location) => ({

‎packages/tui/src/app.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { PermissionProvider } from "./context/permission"
6565
import { DialogModel } from "./component/dialog-model"
6666
import { useConnected } from "./component/use-connected"
6767
import { DialogMcp } from "./component/dialog-mcp"
68+
import { DialogUsage } from "./component/usage"
6869
import { DialogStatus } from "./component/dialog-status"
6970
import { DialogConfig } from "./component/dialog-config"
7071
import { DialogDebug } from "./component/dialog-debug"
@@ -155,6 +156,7 @@ const appBindingCommands = [
155156
"model.cycle_favorite_reverse",
156157
"agent.list",
157158
"mcp.list",
159+
"usage.show",
158160
"agent.cycle",
159161
"agent.cycle.reverse",
160162
"variant.cycle",
@@ -950,6 +952,15 @@ function App() {
950952
dialog.replace(() => <DialogAgent />)
951953
},
952954
},
955+
{
956+
name: "usage.show",
957+
title: "View usage",
958+
category: "Agent",
959+
slash: { name: "usage" },
960+
run: () => {
961+
dialog.replace(() => <DialogUsage />)
962+
},
963+
},
953964
{
954965
name: "mcp.list",
955966
title: "MCP servers",

‎packages/tui/src/component/dialog-model.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ModelInfo } from "@opencode/client"
2-
import { createMemo, createSignal, Show } from "solid-js"
2+
import { createMemo, createSignal, onMount, Show } from "solid-js"
33
import { useLocal } from "../context/local"
44
import { DialogSelect } from "../ui/dialog-select"
55
import { useDialog } from "../ui/dialog"
@@ -13,6 +13,8 @@ import { useLocation } from "../context/location"
1313
import { useNewDesigns } from "../config"
1414
import { DialogFreeModels } from "./dialog-model-free"
1515
import { FavoriteMarker, ModelDetail, isFreeModel, prioritizeFavorites, sortModelOptions } from "./model-options"
16+
import { DialogUsage, useUsageLimits } from "./usage"
17+
import { formatDollars, usageLevel } from "../util/usage-limits"
1618

1719
export function DialogModel(props: { providerID?: string }) {
1820
const local = useLocal()
@@ -60,6 +62,19 @@ function DialogConnectedModels(props: {
6062
const newDesigns = useNewDesigns()
6163
const data = useData()
6264
const location = useLocation()
65+
const dialog = useDialog()
66+
const limits = useUsageLimits()
67+
// Spend moves with every request, so the picker shows fresh limits.
68+
onMount(() => limits.load(true))
69+
const usage = (model: ModelInfo) => {
70+
const value = limits.usage(model)
71+
if (value) {
72+
const level = usageLevel(value, model)
73+
return { text: `${formatDollars(value.left)} left`, danger: level === "out" || level === "reached" }
74+
}
75+
if (limits.unlimited(model)) return { text: "No limit" }
76+
return undefined
77+
}
6378
const [query, setQuery] = createSignal("")
6479
// Under new designs, favoriting does not reorder the list until the dialog is reopened.
6580
const favoritesAtOpen = [...local.model.favorite()]
@@ -105,7 +120,7 @@ function DialogConnectedModels(props: {
105120
description: provider?.name ?? model.providerID,
106121
category,
107122
footer: trailing(model, isFavorite(item)),
108-
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
123+
detail: newDesigns() ? () => <ModelDetail model={model} usage={usage(model)} /> : undefined,
109124
onSelect: () => {
110125
props.onSelect(model.providerID, model.id)
111126
},
@@ -141,7 +156,7 @@ function DialogConnectedModels(props: {
141156
category: connected() ? (freeGroup(model) ? "Free" : (provider?.name ?? model.providerID)) : undefined,
142157
footer: trailing(model, favorite),
143158
free: isFreeModel(model),
144-
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
159+
detail: newDesigns() ? () => <ModelDetail model={model} usage={usage(model)} /> : undefined,
145160
onSelect() {
146161
props.onSelect(model.providerID, model.id)
147162
},
@@ -212,6 +227,13 @@ function DialogConnectedModels(props: {
212227
selection: "none",
213228
onTrigger: props.onConnect,
214229
},
230+
{
231+
command: "model.dialog.usage",
232+
title: newDesigns() ? "usage" : "Usage",
233+
selection: "none",
234+
hidden: !limits.info(),
235+
onTrigger: () => dialog.replace(() => <DialogUsage />),
236+
},
215237
{
216238
command: "model.dialog.favorite",
217239
title: newDesigns() ? (option) => (isFavorite(option?.value) ? "unfavorite" : "favorite") : "Favorite",

‎packages/tui/src/component/model-options.tsx‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,15 @@ export function FavoriteMarker() {
4545
return <span style={{ fg: theme.text.formfield.selected }}>*</span>
4646
}
4747

48-
/** Price, input modalities, context window and reasoning support for the selected model. */
49-
export function ModelDetail(props: { model: ModelInfo; price?: boolean }) {
48+
/**
49+
* Price, input modalities, context window and reasoning support for the selected model, after what its usage limits
50+
* leave when `usage` says so.
51+
*/
52+
export function ModelDetail(props: {
53+
model: ModelInfo
54+
price?: boolean
55+
usage?: { readonly text: string; readonly danger?: boolean }
56+
}) {
5057
const theme = useTheme().surface("dialog")
5158
const price = () => {
5259
if (isFreeModel(props.model)) return "Free"
@@ -60,24 +67,41 @@ export function ModelDetail(props: { model: ModelInfo; price?: boolean }) {
6067
tokens(props.model.limit.context),
6168
...(props.model.variants.length > 0 ? ["Reasoning"] : []),
6269
].filter(Boolean)
70+
const list = (items: string[]) => (
71+
<text fg={theme.text.faint} wrapMode="none" flexShrink={0}>
72+
<For each={items}>
73+
{(fact, index) => (
74+
<>
75+
{index() > 0 && <span style={{ fg: theme.hue.neutral[400] }}> · </span>}
76+
{fact}
77+
</>
78+
)}
79+
</For>
80+
</text>
81+
)
6382
return (
64-
<box flexDirection="row" justifyContent={props.price === false ? "flex-start" : "space-between"} gap={2}>
65-
<Show when={props.price !== false}>
66-
<text fg={theme.text.faint} wrapMode="none">
67-
{price()}
68-
</text>
69-
</Show>
70-
<text fg={theme.text.faint} wrapMode="none" flexShrink={0}>
71-
<For each={facts()}>
72-
{(fact, index) => (
73-
<>
74-
{index() > 0 && <span style={{ fg: theme.hue.neutral[400] }}> · </span>}
75-
{fact}
76-
</>
77-
)}
78-
</For>
79-
</text>
80-
</box>
83+
<Show
84+
when={props.usage}
85+
fallback={
86+
<box flexDirection="row" justifyContent={props.price === false ? "flex-start" : "space-between"} gap={2}>
87+
<Show when={props.price !== false}>
88+
<text fg={theme.text.faint} wrapMode="none">
89+
{price()}
90+
</text>
91+
</Show>
92+
{list(facts())}
93+
</box>
94+
}
95+
>
96+
{(usage) => (
97+
<box flexDirection="row" justifyContent="space-between" gap={2}>
98+
<text fg={usage().danger ? theme.text.feedback.error.base : theme.text.muted} wrapMode="none">
99+
{usage().text}
100+
</text>
101+
{list([...(price() && price() !== "Free" ? [`${price()} per 1M`] : [price()]), ...facts()].filter(Boolean))}
102+
</box>
103+
)}
104+
</Show>
81105
)
82106
}
83107

‎packages/tui/src/component/prompt/index.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { directoryRecentValue } from "../../prompt/directory-completion"
7070
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
7171
import { truncateFilePath } from "../../ui/file-path"
7272
import { PromptMetadataRow } from "./metadata"
73+
import { limitLabel, useUsageLimits } from "../usage"
7374

7475
export type PromptProps = {
7576
sessionID?: string
@@ -1570,6 +1571,20 @@ export function Prompt(props: PromptProps) {
15701571
variant: undefined,
15711572
},
15721573
)
1574+
// What the workspace's limits leave for the selected model, once it is running low. Only OpenCode Console limits
1575+
// models, so only a model it serves loads them.
1576+
const limits = useUsageLimits()
1577+
createEffect(() => {
1578+
const selected = local.model.current()
1579+
if (selected && limits.served(selected.providerID) && limits.info() === undefined) limits.load()
1580+
})
1581+
const limit = createMemo(() => {
1582+
const selected = local.model.current()
1583+
const model = limits
1584+
.models()
1585+
.find((item) => item.providerID === selected?.providerID && item.id === selected?.modelID)
1586+
return model ? limitLabel(limits.usage(model), model) : undefined
1587+
})
15731588
const highlight = createMemo(() => {
15741589
if (muted()) return theme.border.base
15751590
if (store.mode === "shell") return theme.text.action.primary.selected
@@ -1845,6 +1860,7 @@ export function Prompt(props: PromptProps) {
18451860
model={promptDisplay().modelLabel}
18461861
provider={promptDisplay().providerLabel}
18471862
variant={promptDisplay().variant}
1863+
limit={limit()}
18481864
muted={!!muted()}
18491865
highlight={highlight()}
18501866
agentAlpha={agentMetaAlpha()}

‎packages/tui/src/component/prompt/metadata.tsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export function PromptMetadataRow(props: {
1212
model: string
1313
provider: string
1414
variant?: string
15+
/** What the workspace's limits leave for the model, once it is running low. */
16+
limit?: { readonly text: string; readonly danger: boolean }
1517
muted: boolean
1618
highlight: RGBA
1719
agentAlpha: number
@@ -30,6 +32,7 @@ export function PromptMetadataRow(props: {
3032
model: props.model,
3133
provider: props.provider,
3234
variant: props.variant,
35+
limit: props.limit?.text,
3336
})
3437
})
3538

@@ -79,6 +82,18 @@ export function PromptMetadataRow(props: {
7982
</>
8083
)}
8184
</Show>
85+
<Show when={layout().limit ? props.limit : undefined}>
86+
{(limit) => (
87+
<text
88+
flexShrink={0}
89+
marginLeft={1}
90+
wrapMode="none"
91+
fg={fade(limit().danger ? theme.text.feedback.error.base : theme.text.muted, props.modelAlpha)}
92+
>
93+
{limit().text}
94+
</text>
95+
)}
96+
</Show>
8297
</box>
8398
</Show>
8499
</Show>
@@ -96,6 +111,7 @@ type Layout = {
96111
model: string
97112
provider?: string
98113
variant?: string
114+
limit?: string
99115
}
100116

101117
function promptMetadataLayout(input: {
@@ -106,32 +122,36 @@ function promptMetadataLayout(input: {
106122
model: string
107123
provider: string
108124
variant?: string
125+
limit?: string
109126
}) {
110127
const agent = input.terminalWidth < 44 ? undefined : input.agent
111128
const provider = input.terminalWidth < 44 ? "" : input.provider
129+
const limit = input.limit
112130
const candidates: Layout[] = [
113-
{ agent, auto: input.auto, model: input.model, provider, variant: input.variant },
114-
{ agent, model: input.model, provider, variant: input.variant },
131+
{ agent, auto: input.auto, model: input.model, provider, variant: input.variant, limit },
132+
{ agent, model: input.model, provider, variant: input.variant, limit },
115133
{
116134
agent,
117135
model: input.model,
118136
provider: provider.split(" / ").at(-1) ?? provider,
119137
variant: input.variant,
138+
limit,
120139
},
121-
{ agent, model: input.model, variant: input.variant },
140+
{ agent, model: input.model, variant: input.variant, limit },
122141
]
123142
const fit = candidates.find((candidate) => stringWidth(text(candidate)) <= input.width)
124143
if (fit) return fit
125144

126145
const prefix = agent ? `${agent} · ` : ""
127-
const suffix = input.variant ? ` · ${input.variant}` : ""
146+
const suffix = `${input.variant ? ` · ${input.variant}` : ""}${limit ? ` ${limit}` : ""}`
128147
return {
129148
agent,
130149
model: Locale.truncateWidth(
131150
input.model,
132151
Math.max(9, input.width - stringWidth(prefix) - stringWidth(suffix)),
133152
).replace(/\s+…$/, "…"),
134153
variant: input.variant,
154+
limit,
135155
}
136156
}
137157

@@ -142,5 +162,6 @@ function text(input: Layout) {
142162
...(input.model ? [...(input.agent ? ["·"] : []), input.model] : []),
143163
...(input.provider ? [input.provider] : []),
144164
...(input.variant ? ["·", input.variant] : []),
165+
...(input.limit ? [` ${input.limit}`] : []),
145166
].join(" ")
146167
}

0 commit comments

Comments
 (0)