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
23 changes: 23 additions & 0 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
SessionInboxCompaction,
ShellInfo,
SkillInfo,
UsageLimitsInfo,
VcsInfo,
OpenCodeEvent,
OpenCodeClient,
Expand Down Expand Up @@ -103,6 +104,9 @@ type LocationData = {
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
websearch?: WebSearchProvider[]
// The Go allowance and Console budgets of the OpenCode Console workspace the location is signed in to; null when it
// isn't signed in.
usageLimits?: UsageLimitsInfo | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellWithLocation>
Expand Down Expand Up @@ -942,6 +946,12 @@ export function createData(config: CreateDataInput) {
return
}
case "session.step.failed":
// A budget refusal means the cached usage limits are out of date, which is what the refusal sends people to.
if (
event.location &&
(event.data.error.type === "provider.budget-limit" || event.data.error.type === "provider.model-budget-limit")
)
refreshUsageLimits(event.location)
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
Expand Down Expand Up @@ -1227,6 +1237,7 @@ export function createData(config: CreateDataInput) {
Object.keys(store.location).forEach((key) => {
const ref = JSON.parse(key) as [string, string | null]
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
refreshUsageLimits(location)
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
refresh(() => result.location.integration.sync(location))
Expand Down Expand Up @@ -1311,6 +1322,7 @@ export function createData(config: CreateDataInput) {
refresh(() => result.location.reference.sync(location))
break
case "integration.updated":
refreshUsageLimits(location)
result.location.integration.invalidate(location)
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
Expand Down Expand Up @@ -1348,6 +1360,15 @@ export function createData(config: CreateDataInput) {
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
// Reloads usage limits only where something has read them, since each load asks OpenCode Console.
function refreshUsageLimits(location: LocationRef) {
const loaded =
store.location[locationKey(location)]?.usageLimits !== undefined ||
sync.has(`location.usageLimits:${locationKey(location)}`)
usageLimits.invalidate(location)
if (loaded) refresh(() => usageLimits.sync(location))
}

function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
Expand All @@ -1371,6 +1392,7 @@ export function createData(config: CreateDataInput) {
}

const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const usageLimits = locationResource("usageLimits", (location) => api().usage.limits({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory }
Expand Down Expand Up @@ -1982,6 +2004,7 @@ export function createData(config: CreateDataInput) {
result.session.form.invalidate("global", location)
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
usage: { limits: usageLimits.list, sync: usageLimits.sync, invalidate: usageLimits.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
config: locationResource("config", async (location) => ({
Expand Down
11 changes: 11 additions & 0 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { PermissionProvider } from "./context/permission"
import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogUsage } from "./component/usage"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
Expand Down Expand Up @@ -155,6 +156,7 @@ const appBindingCommands = [
"model.cycle_favorite_reverse",
"agent.list",
"mcp.list",
"usage.show",
"agent.cycle",
"agent.cycle.reverse",
"variant.cycle",
Expand Down Expand Up @@ -950,6 +952,15 @@ function App() {
dialog.replace(() => <DialogAgent />)
},
},
{
name: "usage.show",
title: "View usage",
category: "Agent",
slash: { name: "usage" },
run: () => {
dialog.replace(() => <DialogUsage />)
},
},
{
name: "mcp.list",
title: "MCP servers",
Expand Down
28 changes: 25 additions & 3 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ModelInfo } from "@opencode/client"
import { createMemo, createSignal, Show } from "solid-js"
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
Expand All @@ -13,6 +13,8 @@ import { useLocation } from "../context/location"
import { useNewDesigns } from "../config"
import { DialogFreeModels } from "./dialog-model-free"
import { FavoriteMarker, ModelDetail, isFreeModel, prioritizeFavorites, sortModelOptions } from "./model-options"
import { DialogUsage, useUsageLimits } from "./usage"
import { formatDollars, usageLevel } from "../util/usage-limits"

export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
Expand Down Expand Up @@ -60,6 +62,19 @@ function DialogConnectedModels(props: {
const newDesigns = useNewDesigns()
const data = useData()
const location = useLocation()
const dialog = useDialog()
const limits = useUsageLimits()
// Spend moves with every request, so the picker shows fresh limits.
onMount(() => limits.load(true))
const usage = (model: ModelInfo) => {
const value = limits.usage(model)
if (value) {
const level = usageLevel(value, model)
return { text: `${formatDollars(value.left)} left`, danger: level === "out" || level === "reached" }
}
if (limits.unlimited(model)) return { text: "No limit" }
return undefined
}
const [query, setQuery] = createSignal("")
// Under new designs, favoriting does not reorder the list until the dialog is reopened.
const favoritesAtOpen = [...local.model.favorite()]
Expand Down Expand Up @@ -105,7 +120,7 @@ function DialogConnectedModels(props: {
description: provider?.name ?? model.providerID,
category,
footer: trailing(model, isFavorite(item)),
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
detail: newDesigns() ? () => <ModelDetail model={model} usage={usage(model)} /> : undefined,
onSelect: () => {
props.onSelect(model.providerID, model.id)
},
Expand Down Expand Up @@ -141,7 +156,7 @@ function DialogConnectedModels(props: {
category: connected() ? (freeGroup(model) ? "Free" : (provider?.name ?? model.providerID)) : undefined,
footer: trailing(model, favorite),
free: isFreeModel(model),
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
detail: newDesigns() ? () => <ModelDetail model={model} usage={usage(model)} /> : undefined,
onSelect() {
props.onSelect(model.providerID, model.id)
},
Expand Down Expand Up @@ -212,6 +227,13 @@ function DialogConnectedModels(props: {
selection: "none",
onTrigger: props.onConnect,
},
{
command: "model.dialog.usage",
title: newDesigns() ? "usage" : "Usage",
selection: "none",
hidden: !limits.info(),
onTrigger: () => dialog.replace(() => <DialogUsage />),
},
{
command: "model.dialog.favorite",
title: newDesigns() ? (option) => (isFavorite(option?.value) ? "unfavorite" : "favorite") : "Favorite",
Expand Down
62 changes: 43 additions & 19 deletions packages/tui/src/component/model-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,15 @@ export function FavoriteMarker() {
return <span style={{ fg: theme.text.formfield.selected }}>*</span>
}

/** Price, input modalities, context window and reasoning support for the selected model. */
export function ModelDetail(props: { model: ModelInfo; price?: boolean }) {
/**
* Price, input modalities, context window and reasoning support for the selected model, after what its usage limits
* leave when `usage` says so.
*/
export function ModelDetail(props: {
model: ModelInfo
price?: boolean
usage?: { readonly text: string; readonly danger?: boolean }
}) {
const theme = useTheme().surface("dialog")
const price = () => {
if (isFreeModel(props.model)) return "Free"
Expand All @@ -60,24 +67,41 @@ export function ModelDetail(props: { model: ModelInfo; price?: boolean }) {
tokens(props.model.limit.context),
...(props.model.variants.length > 0 ? ["Reasoning"] : []),
].filter(Boolean)
const list = (items: string[]) => (
<text fg={theme.text.faint} wrapMode="none" flexShrink={0}>
<For each={items}>
{(fact, index) => (
<>
{index() > 0 && <span style={{ fg: theme.hue.neutral[400] }}> · </span>}
{fact}
</>
)}
</For>
</text>
)
return (
<box flexDirection="row" justifyContent={props.price === false ? "flex-start" : "space-between"} gap={2}>
<Show when={props.price !== false}>
<text fg={theme.text.faint} wrapMode="none">
{price()}
</text>
</Show>
<text fg={theme.text.faint} wrapMode="none" flexShrink={0}>
<For each={facts()}>
{(fact, index) => (
<>
{index() > 0 && <span style={{ fg: theme.hue.neutral[400] }}> · </span>}
{fact}
</>
)}
</For>
</text>
</box>
<Show
when={props.usage}
fallback={
<box flexDirection="row" justifyContent={props.price === false ? "flex-start" : "space-between"} gap={2}>
<Show when={props.price !== false}>
<text fg={theme.text.faint} wrapMode="none">
{price()}
</text>
</Show>
{list(facts())}
</box>
}
>
{(usage) => (
<box flexDirection="row" justifyContent="space-between" gap={2}>
<text fg={usage().danger ? theme.text.feedback.error.base : theme.text.muted} wrapMode="none">
{usage().text}
</text>
{list([...(price() && price() !== "Free" ? [`${price()} per 1M`] : [price()]), ...facts()].filter(Boolean))}
</box>
)}
</Show>
)
}

Expand Down
16 changes: 16 additions & 0 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { directoryRecentValue } from "../../prompt/directory-completion"
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
import { truncateFilePath } from "../../ui/file-path"
import { PromptMetadataRow } from "./metadata"
import { limitLabel, useUsageLimits } from "../usage"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -1570,6 +1571,20 @@ export function Prompt(props: PromptProps) {
variant: undefined,
},
)
// What the workspace's limits leave for the selected model, once it is running low. Only OpenCode Console limits
// models, so only a model it serves loads them.
const limits = useUsageLimits()
createEffect(() => {
const selected = local.model.current()
if (selected && limits.served(selected.providerID) && limits.info() === undefined) limits.load()
})
const limit = createMemo(() => {
const selected = local.model.current()
const model = limits
.models()
.find((item) => item.providerID === selected?.providerID && item.id === selected?.modelID)
return model ? limitLabel(limits.usage(model), model) : undefined
})
const highlight = createMemo(() => {
if (muted()) return theme.border.base
if (store.mode === "shell") return theme.text.action.primary.selected
Expand Down Expand Up @@ -1845,6 +1860,7 @@ export function Prompt(props: PromptProps) {
model={promptDisplay().modelLabel}
provider={promptDisplay().providerLabel}
variant={promptDisplay().variant}
limit={limit()}
muted={!!muted()}
highlight={highlight()}
agentAlpha={agentMetaAlpha()}
Expand Down
29 changes: 25 additions & 4 deletions packages/tui/src/component/prompt/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export function PromptMetadataRow(props: {
model: string
provider: string
variant?: string
/** What the workspace's limits leave for the model, once it is running low. */
limit?: { readonly text: string; readonly danger: boolean }
muted: boolean
highlight: RGBA
agentAlpha: number
Expand All @@ -30,6 +32,7 @@ export function PromptMetadataRow(props: {
model: props.model,
provider: props.provider,
variant: props.variant,
limit: props.limit?.text,
})
})

Expand Down Expand Up @@ -79,6 +82,18 @@ export function PromptMetadataRow(props: {
</>
)}
</Show>
<Show when={layout().limit ? props.limit : undefined}>
{(limit) => (
<text
flexShrink={0}
marginLeft={1}
wrapMode="none"
fg={fade(limit().danger ? theme.text.feedback.error.base : theme.text.muted, props.modelAlpha)}
>
{limit().text}
</text>
)}
</Show>
</box>
</Show>
</Show>
Expand All @@ -96,6 +111,7 @@ type Layout = {
model: string
provider?: string
variant?: string
limit?: string
}

function promptMetadataLayout(input: {
Expand All @@ -106,32 +122,36 @@ function promptMetadataLayout(input: {
model: string
provider: string
variant?: string
limit?: string
}) {
const agent = input.terminalWidth < 44 ? undefined : input.agent
const provider = input.terminalWidth < 44 ? "" : input.provider
const limit = input.limit
const candidates: Layout[] = [
{ agent, auto: input.auto, model: input.model, provider, variant: input.variant },
{ agent, model: input.model, provider, variant: input.variant },
{ agent, auto: input.auto, model: input.model, provider, variant: input.variant, limit },
{ agent, model: input.model, provider, variant: input.variant, limit },
{
agent,
model: input.model,
provider: provider.split(" / ").at(-1) ?? provider,
variant: input.variant,
limit,
},
{ agent, model: input.model, variant: input.variant },
{ agent, model: input.model, variant: input.variant, limit },
]
const fit = candidates.find((candidate) => stringWidth(text(candidate)) <= input.width)
if (fit) return fit

const prefix = agent ? `${agent} · ` : ""
const suffix = input.variant ? ` · ${input.variant}` : ""
const suffix = `${input.variant ? ` · ${input.variant}` : ""}${limit ? ` ${limit}` : ""}`
return {
agent,
model: Locale.truncateWidth(
input.model,
Math.max(9, input.width - stringWidth(prefix) - stringWidth(suffix)),
).replace(/\s+…$/, "…"),
variant: input.variant,
limit,
}
}

Expand All @@ -142,5 +162,6 @@ function text(input: Layout) {
...(input.model ? [...(input.agent ? ["·"] : []), input.model] : []),
...(input.provider ? [input.provider] : []),
...(input.variant ? ["·", input.variant] : []),
...(input.limit ? [` ${input.limit}`] : []),
].join(" ")
}
Loading
Loading