Skip to content
Open
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
170 changes: 170 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { For, Show } from "solid-js"

type Theme = ReturnType<typeof useTheme>["theme"]

type UsageWindow = {
usedPercent: number
windowMinutes: number | null
resetsAt: number | null
}

export type UsageEntry = {
provider: string
displayName: string
snapshot: {
primary: UsageWindow | null
secondary: UsageWindow | null
credits: {
hasCredits: boolean
unlimited: boolean
balance: string | null
} | null
planType: string | null
updatedAt: number
}
}

export function DialogUsage(props: { entries: UsageEntry[] }) {
const { theme } = useTheme()

return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1} flexDirection="column">
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Usage
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={props.entries.length > 0} fallback={<text fg={theme.text}>No usage data available.</text>}>
<For each={props.entries}>
{(entry, index) => {
const mergeReset = entry.provider === "copilot"
const resetAt = entry.snapshot.primary?.resetsAt ?? entry.snapshot.secondary?.resetsAt ?? null
return (
<box flexDirection="column" marginTop={index() === 0 ? 0 : 1} gap={1}>
<box flexDirection="column">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{entry.displayName} Usage ({formatPlanType(entry.snapshot.planType)} Plan)
</text>
<text fg={theme.textMuted}>{"─".repeat(Math.max(24, entry.displayName.length + 20))}</text>
</box>
<Show when={entry.snapshot.primary}>
{(window) => (
<box flexDirection="column">
{renderWindow(getWindowLabel(entry.provider, "primary"), window(), theme, !mergeReset)}
</box>
)}
</Show>
<Show when={entry.snapshot.secondary}>
{(window) => (
<box flexDirection="column">
{renderWindow(getWindowLabel(entry.provider, "secondary"), window(), theme, !mergeReset)}
</box>
)}
</Show>
<Show when={mergeReset && resetAt !== null}>
<text fg={theme.textMuted}>Resets {formatResetTime(resetAt!)}</text>
</Show>
<Show when={entry.snapshot.credits}>
{(credits) => <text fg={theme.text}>{formatCreditsLabel(entry.provider, credits())}</text>}
</Show>
</box>
)
}}
</For>
</Show>
</box>
)
}

function getWindowLabel(provider: string, windowType: "primary" | "secondary"): string {
if (provider === "copilot") {
return windowType === "primary" ? "Chat" : "Completions"
}
return windowType === "primary" ? "Hourly" : "Weekly"
}

function renderWindow(label: string, window: UsageWindow, theme: Theme, showReset = true) {
const usedPercent = clampPercent(window.usedPercent)
const bar = renderProgressBar(usedPercent)
const windowLabel = formatWindowLabel(label, window.windowMinutes)

return (
<box flexDirection="column">
<text fg={theme.text}>
{windowLabel} Limit: {bar} {usedPercent.toFixed(0)}% used
</text>
<Show when={showReset && window.resetsAt !== null}>
<text fg={theme.textMuted}>Resets {formatResetTime(window.resetsAt!)}</text>
</Show>
</box>
)
}

function formatWindowLabel(base: string, windowMinutes: number | null): string {
if (!windowMinutes) return base
const minutesPerHour = 60
const minutesPerDay = 24 * minutesPerHour
if (windowMinutes <= minutesPerDay) {
const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour))
if (hours === 1) return "Hourly"
return `${hours}h`
}
return base
}

function formatResetTime(resetAt: number): string {
const now = Math.floor(Date.now() / 1000)
const diff = resetAt - now
if (diff <= 0) return "now"
if (diff < 60) return `in ${diff} seconds`
if (diff < 3600) return `in ${Math.round(diff / 60)} minutes`
if (diff < 86400) return `in ${Math.round(diff / 3600)} hours`
return `in ${Math.round(diff / 86400)} days`
}

function renderProgressBar(usedPercent: number, width = 10): string {
const filled = Math.round((usedPercent / 100) * width)
const empty = width - filled
return `[${"█".repeat(filled)}${"░".repeat(empty)}]`
}

function formatPlanType(planType: string | null): string {
if (!planType) return "Unknown"
const normalized = planType.replace(/_/g, " ")
const parts: string[] = []
for (const part of normalized.split(" ")) {
if (!part) continue
parts.push(part.slice(0, 1).toUpperCase() + part.slice(1))
}
return parts.join(" ")
}

function formatCreditsLabel(
provider: string,
credits: { hasCredits: boolean; unlimited: boolean; balance: string | null },
): string {
if (provider === "copilot") {
if (credits.unlimited) return "Quota: Unlimited"
if (credits.balance) return `Quota: ${credits.balance}`
if (!credits.hasCredits) return "Quota: Exhausted"
return "Quota: Available"
}
return `Credits: ${formatCredits(credits)}`
}

function formatCredits(credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }): string {
if (!credits.hasCredits) return "None"
if (credits.unlimited) return "Unlimited"
if (credits.balance) return credits.balance
return "Available"
}

function clampPercent(value: number): number {
if (Number.isNaN(value)) return 0
if (value < 0) return 0
if (value > 100) return 100
return value
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
Expand Down Expand Up @@ -73,6 +73,7 @@ export function Autocomplete(props: {
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
onUsage: (command: string) => void
}) {
const sdk = useSDK()
const sync = useSync()
Expand Down Expand Up @@ -424,6 +425,11 @@ export function Autocomplete(props: {
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/usage",
description: "show usage limits",
onSelect: () => props.onUsage("/usage"),
},
{
display: "/mcp",
description: "toggle MCPs",
Expand Down Expand Up @@ -686,7 +692,7 @@ export function Autocomplete(props: {
height={height()}
scrollbarOptions={{ visible: false }}
>
<For
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
Expand All @@ -698,20 +704,22 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display}
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option.description}
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
</text>
</Show>
</box>
)}
</For>
</Index>
</scrollbox>
</box>
)
Expand Down
72 changes: 61 additions & 11 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { DialogUsage, type UsageEntry } from "../dialog-usage"
import { useTextareaKeybindings } from "../textarea-keybindings"

export type PromptProps = {
Expand Down Expand Up @@ -73,6 +74,34 @@ export function Prompt(props: PromptProps) {
const { theme, syntax } = useTheme()
const kv = useKV()

function handleUsageCommand(commandText: string) {
const parts = commandText.trim().split(/\s+/)
const provider = parts.length > 1 && !parts[1].startsWith("-") ? parts[1] : undefined
const refresh = parts.some((part) => part === "--refresh" || part === "-r")

type UsageResponse = {
entries: UsageEntry[]
error?: string
}

sdk.client.usage
.get({ provider, refresh })
.then((res) => {
const data = res.data as UsageResponse | undefined
if (!data) return
if (data.entries.length > 0) {
dialog.replace(() => <DialogUsage entries={data.entries} />)
return
}
const message = data.error ?? "No usage data available."
DialogAlert.show(dialog, "Usage", message)
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
DialogAlert.show(dialog, "Usage", message)
})
}

function promptModelWarning() {
toast.show({
variant: "warning",
Expand Down Expand Up @@ -486,7 +515,7 @@ export function Prompt(props: PromptProps) {

async function submit() {
if (props.disabled) return
if (autocomplete?.visible) return
if (autocomplete?.visible && !store.prompt.input.startsWith("/usage")) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
Expand Down Expand Up @@ -530,7 +559,16 @@ export function Prompt(props: PromptProps) {
const currentMode = store.mode
const variant = local.model.variant.current()

if (store.mode === "shell") {
const isShell = store.mode === "shell"
const isUsage = inputText.startsWith("/usage")
const isCommand =
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
})

if (isShell) {
sdk.client.session.shell({
sessionID,
agent: local.agent.current().name,
Expand All @@ -541,15 +579,23 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
console.log(command)
return sync.data.command.some((x) => x.name === command)
}

if (isUsage) {
handleUsageCommand(inputText)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
) {
let [command, ...args] = inputText.split(" ")
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
input.clear()
return
}

if (isCommand) {
const [command, ...args] = inputText.split(" ")
sdk.client.session.command({
sessionID,
command: command.slice(1),
Expand All @@ -565,7 +611,9 @@ export function Prompt(props: PromptProps) {
...x,
})),
})
} else {
}

if (!isShell && !isUsage && !isCommand) {
sdk.client.session.prompt({
sessionID,
...selectedModel,
Expand All @@ -586,6 +634,7 @@ export function Prompt(props: PromptProps) {
],
})
}

history.append({
...store.prompt,
mode: currentMode,
Expand Down Expand Up @@ -741,6 +790,7 @@ export function Prompt(props: PromptProps) {
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
onUsage={handleUsageCommand}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
Expand Down
Loading