refactor(ui): centralize provider usage components

This commit is contained in:
Josh Lambert
2026-08-05 15:33:39 -04:00
parent e4e2069cd0
commit 3b04f6d024
11 changed files with 355 additions and 220 deletions
+1
View File
@@ -1,5 +1,6 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-ui": minor
"kilo-code": minor
---
+1
View File
@@ -32,6 +32,7 @@
"./app-icon": "./src/components/app-icon.tsx",
"./markdown": "./src/components/markdown.tsx",
"./keybind": "./src/components/keybind.tsx",
"./kilo-pass-meter": "./src/components/kilo-pass-meter.tsx",
"./popover": "./src/components/popover.tsx",
"./hover-card": "./src/components/hover-card.tsx",
"./dropdown-menu": "./src/components/dropdown-menu.tsx",
+8
View File
@@ -5,6 +5,14 @@
padding: 8px;
border: 1px solid var(--border-weak-base);
[data-slot="card-header"] {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 12px;
}
&[data-variant="error"] {
padding: 12px;
background-color: color-mix(in srgb, var(--surface-critical-strong) 10%, var(--surface-inset-base));
+11
View File
@@ -1 +1,12 @@
import { type ComponentProps, splitProps } from "solid-js"
export * from "@opencode-ai/ui/card"
export function CardHeader(props: ComponentProps<"div">) {
const [local, rest] = splitProps(props, ["children", "class", "classList"])
return (
<div {...rest} data-slot="card-header" classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}>
{local.children}
</div>
)
}
@@ -0,0 +1,118 @@
[data-component="kilo-pass-meter"] {
display: flex;
flex-direction: column;
gap: 5px;
[data-slot="kilo-pass-meter-header"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
color: var(--text-base);
font-size: var(--font-size-small);
}
[data-slot="kilo-pass-meter-header"] strong {
text-align: right;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
[data-slot="kilo-pass-meter-track"] {
position: relative;
height: 8px;
overflow: visible;
border-radius: 4px;
background: var(--border-weak-base);
}
:is(
[data-slot="kilo-pass-meter-paid-background"],
[data-slot="kilo-pass-meter-bonus-background"],
[data-slot="kilo-pass-meter-paid-fill"],
[data-slot="kilo-pass-meter-bonus-fill"]
) {
position: absolute;
inset-block: 0;
}
[data-slot="kilo-pass-meter-paid-background"] {
left: 0;
background: color-mix(in srgb, var(--icon-warning-base) 18%, transparent);
}
[data-slot="kilo-pass-meter-bonus-background"] {
background: color-mix(in srgb, var(--icon-success-base) 18%, transparent);
}
[data-slot="kilo-pass-meter-paid-fill"] {
left: 0;
border-radius: 4px 0 0 4px;
background: var(--icon-warning-base);
}
[data-slot="kilo-pass-meter-bonus-fill"] {
border-radius: 0 4px 4px 0;
background: var(--icon-success-base);
}
[data-slot="kilo-pass-meter-boundary"] {
position: absolute;
top: 100%;
width: 2px;
height: 5px;
margin-left: -1px;
background: var(--text-weak);
}
[data-slot="kilo-pass-meter-amounts"] {
position: relative;
height: 15px;
color: var(--icon-warning-base);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
[data-slot="kilo-pass-meter-amounts"] > span {
position: absolute;
transform: translateX(-50%);
}
[data-slot="kilo-pass-meter-bonus-amount"] {
right: 0;
left: auto !important;
color: var(--icon-success-base);
transform: none !important;
}
[data-slot="kilo-pass-meter-legend"],
[data-slot="kilo-pass-meter-legend"] > span {
display: flex;
align-items: center;
}
[data-slot="kilo-pass-meter-legend"] {
justify-content: space-between;
color: var(--text-weak);
font-size: var(--font-size-small);
}
[data-slot="kilo-pass-meter-legend"] > span {
gap: 6px;
}
[data-slot="kilo-pass-meter-legend"] i {
width: 8px;
height: 8px;
border-radius: 50%;
}
[data-slot="kilo-pass-meter-paid-dot"] {
background: var(--icon-warning-base);
}
[data-slot="kilo-pass-meter-bonus-dot"] {
background: var(--icon-success-base);
}
}
@@ -0,0 +1,97 @@
import { type ComponentProps, type JSX, splitProps } from "solid-js"
export interface KiloPassMeterProps extends Omit<ComponentProps<"div">, "children"> {
used: number
paid: number
bonus: number
label: JSX.Element
paidLabel: JSX.Element
bonusLabel: JSX.Element
format: (value: number) => string
}
export function KiloPassMeter(props: KiloPassMeterProps) {
const [local, rest] = splitProps(props, [
"used",
"paid",
"bonus",
"label",
"paidLabel",
"bonusLabel",
"format",
"class",
"classList",
])
const model = () => {
const paid = Math.max(0, local.paid)
const bonus = Math.max(0, local.bonus)
const used = Math.max(0, local.used)
const total = paid + bonus
const boundary = total > 0 ? (paid / total) * 100 : 100
const filled = total > 0 ? Math.min(100, (used / total) * 100) : 0
return {
paid,
bonus,
used,
total,
boundary,
paidFill: Math.min(filled, boundary),
bonusFill: Math.max(0, filled - boundary),
}
}
return (
<div
{...rest}
data-component="kilo-pass-meter"
role="meter"
aria-valuemin={0}
aria-valuemax={Math.max(model().total, 1)}
aria-valuenow={Math.min(model().used, Math.max(model().total, 1))}
aria-valuetext={`${local.format(model().used)} / ${local.format(model().total)}`}
classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}
>
<div data-slot="kilo-pass-meter-header">
<span>{local.label}</span>
<strong>
{local.format(model().used)} / {local.format(model().total)}
</strong>
</div>
<div data-slot="kilo-pass-meter-track" aria-hidden="true">
<div data-slot="kilo-pass-meter-paid-background" style={{ width: `${model().boundary}%` }} />
<div
data-slot="kilo-pass-meter-bonus-background"
hidden={model().bonus <= 0}
style={{ left: `${model().boundary}%`, width: `${100 - model().boundary}%` }}
/>
<div data-slot="kilo-pass-meter-paid-fill" style={{ width: `${model().paidFill}%` }} />
<div
data-slot="kilo-pass-meter-bonus-fill"
hidden={model().bonusFill <= 0}
style={{ left: `${model().boundary}%`, width: `${model().bonusFill}%` }}
/>
<div
data-slot="kilo-pass-meter-boundary"
hidden={model().bonus <= 0}
style={{ left: `${model().boundary}%` }}
/>
</div>
<div data-slot="kilo-pass-meter-amounts" aria-hidden="true">
<span style={{ left: `${model().boundary}%` }}>{local.format(model().paid)}</span>
<span data-slot="kilo-pass-meter-bonus-amount" hidden={model().bonus <= 0}>
{local.format(model().bonus)}
</span>
</div>
<div data-slot="kilo-pass-meter-legend">
<span>
<i data-slot="kilo-pass-meter-paid-dot" aria-hidden="true" />
{local.paidLabel}
</span>
<span hidden={model().bonus <= 0}>
<i data-slot="kilo-pass-meter-bonus-dot" aria-hidden="true" />
{local.bonusLabel}
</span>
</div>
</div>
)
}
+16 -1
View File
@@ -1,6 +1,7 @@
/** @jsxImportSource solid-js */
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { Card } from "@opencode-ai/ui/card"
import { Card, CardDescription, CardHeader, CardTitle } from "../components/card"
import { Tag } from "../components/tag"
const meta: Meta<typeof Card> = {
title: "Components/Card",
@@ -33,6 +34,20 @@ export const Info: Story = {
args: { variant: "info", children: "This is an info card" },
}
export const WithHeader: Story = {
render: () => (
<Card>
<CardHeader>
<div>
<CardTitle icon={false}>MiniMax</CardTitle>
<CardDescription>Token Plan Plus</CardDescription>
</div>
<Tag>Direct</Tag>
</CardHeader>
</Card>
),
}
export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", "flex-direction": "column", gap: "8px", width: "300px" }}>
@@ -0,0 +1,39 @@
/** @jsxImportSource solid-js */
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { KiloPassMeter } from "../components/kilo-pass-meter"
const meta: Meta<typeof KiloPassMeter> = {
title: "Components/Kilo Pass Meter",
component: KiloPassMeter,
decorators: [
(Story) => (
<div style={{ padding: "16px", width: "320px" }}>
<Story />
</div>
),
],
parameters: { layout: "centered" },
}
export default meta
type Story = StoryObj<typeof KiloPassMeter>
const format = (value: number) => `$${value.toFixed(2)}`
const render = (used: number, paid: number, bonus: number) => (
<KiloPassMeter
used={used}
paid={paid}
bonus={bonus}
label="This month's usage"
paidLabel="Paid"
bonusLabel="Bonus"
format={format}
aria-label="Kilo Pass monthly usage"
/>
)
export const CurrentPlan: Story = { render: () => render(73.27, 199, 99.5) }
export const UsingBonus: Story = { render: () => render(240, 199, 99.5) }
export const PaidOnly: Story = { render: () => render(73.27, 199, 0) }
export const Empty: Story = { render: () => render(0, 0, 0) }
export const OverLimit: Story = { render: () => render(325, 199, 99.5) }
+1
View File
@@ -22,6 +22,7 @@
@import "../components/error-details.css";
@import "../components/icon-button.css";
@import "../components/inline-input.css";
@import "../components/kilo-pass-meter.css";
@import "../components/list.css";
@import "../components/markdown.css";
@import "../components/message-part.css";
@@ -2,7 +2,8 @@ import { Component, For, Show } from "solid-js"
import type { KiloPassState, ProviderUsageData } from "../../types/messages"
import type { ProviderUsageSnapshot } from "@kilocode/sdk/v2/client"
import { Button } from "@kilocode/kilo-ui/button"
import { Card, CardActions, CardDescription, CardTitle } from "@kilocode/kilo-ui/card"
import { Card, CardActions, CardDescription, CardHeader, CardTitle } from "@kilocode/kilo-ui/card"
import { KiloPassMeter } from "@kilocode/kilo-ui/kilo-pass-meter"
import { Progress } from "@kilocode/kilo-ui/progress"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tag } from "@kilocode/kilo-ui/tag"
@@ -55,17 +56,19 @@ const UsageCard: Component<{
onOpen: (url: string) => void
language: Language
}> = (props) => (
<Card class="provider-usage-card" variant={variant(props.item)}>
<div class="provider-usage-heading">
<Card variant={variant(props.item)}>
<CardHeader>
<div>
<CardTitle icon={false}>{props.item.providerLabel}</CardTitle>
<CardTitle icon={false} role="heading" aria-level={4}>
{props.item.providerLabel}
</CardTitle>
<CardDescription>{props.item.planLabel}</CardDescription>
</div>
<Tag>{source(props.item, props.language)}</Tag>
</div>
</CardHeader>
<Show when={props.item.planState !== "active"}>
<p class="provider-usage-meta">
<CardDescription>
{props.language.t(
props.item.planState === "past_due"
? "profile.usage.plan.pastDue"
@@ -73,7 +76,7 @@ const UsageCard: Component<{
? "profile.usage.plan.canceling"
: "profile.usage.plan.unknown",
)}
</p>
</CardDescription>
</Show>
<Show when={props.item.fetchState !== "ready"}>
@@ -88,25 +91,33 @@ const UsageCard: Component<{
<For each={props.item.windows}>
{(window) => {
const progress = () => windowProgress(window)
const value = () => formatWindowValue(window, labels(props.language))
return (
<div class="provider-usage-row">
<div class="provider-usage-row-heading">
<span>{window.label}</span>
<strong>{formatWindowValue(window, labels(props.language))}</strong>
</div>
<Show when={progress() !== undefined}>
<Progress
value={progress()}
minValue={0}
maxValue={100}
aria-label={`${window.label}: ${formatWindowValue(window, labels(props.language))}`}
/>
showValueLabel
getValueLabel={value}
aria-label={`${window.label}: ${value()}`}
aria-valuetext={value()}
>
{window.label}
</Progress>
</Show>
<Show when={progress() === undefined}>
<div class="provider-usage-summary">
<span>{window.label}</span>
<strong>{value()}</strong>
</div>
</Show>
<Show when={window.resetAt}>
{(reset) => (
<span class="provider-usage-meta">
<CardDescription>
{props.language.t("profile.usage.reset", { date: new Date(reset()).toLocaleString() })}
</span>
</CardDescription>
)}
</Show>
</div>
@@ -140,74 +151,7 @@ const UsageCard: Component<{
</Card>
)
const KiloPassUsage: Component<{ pass: KiloPassState }> = (props) => {
const language = useLanguage()
const model = () => {
const paid = Math.max(0, props.pass.currentPeriodBaseCreditsUsd)
const bonus = Math.max(0, props.pass.currentPeriodBonusCreditsUsd)
const used = Math.max(0, props.pass.currentPeriodUsageUsd)
const total = paid + bonus
const paidWidth = total > 0 ? (paid / total) * 100 : 100
const usedWidth = total > 0 ? Math.min(100, (used / total) * 100) : 0
return {
paid,
bonus,
used,
total,
paidWidth,
paidFill: Math.min(usedWidth, paidWidth),
bonusFill: Math.max(0, usedWidth - paidWidth),
}
}
return (
<div class="provider-usage-row kilo-pass-usage">
<div class="provider-usage-row-heading">
<span>This month&apos;s usage</span>
<strong>
${model().used.toFixed(2)} / ${model().total.toFixed(2)}
</strong>
</div>
<div
class="kilo-pass-usage-track"
role="progressbar"
aria-label={`Kilo Pass: $${model().used.toFixed(2)} used of $${model().total.toFixed(2)}`}
aria-valuemin={0}
aria-valuemax={model().total}
aria-valuenow={model().used}
>
<div class="kilo-pass-usage-paid-background" style={{ width: `${model().paidWidth}%` }} />
<div
class="kilo-pass-usage-bonus-background"
hidden={model().bonus <= 0}
style={{ left: `${model().paidWidth}%`, width: `${100 - model().paidWidth}%` }}
/>
<div class="kilo-pass-usage-paid-fill" style={{ width: `${model().paidFill}%` }} />
<div
class="kilo-pass-usage-bonus-fill"
hidden={model().bonusFill <= 0}
style={{ left: `${model().paidWidth}%`, width: `${model().bonusFill}%` }}
/>
<div class="kilo-pass-usage-boundary" hidden={model().bonus <= 0} style={{ left: `${model().paidWidth}%` }} />
</div>
<div class="kilo-pass-usage-amounts">
<span style={{ left: `${model().paidWidth}%` }}>${model().paid.toFixed(2)}</span>
<span class="kilo-pass-usage-bonus-amount" hidden={model().bonus <= 0}>
${model().bonus.toFixed(2)}
</span>
</div>
<div class="kilo-pass-usage-legend">
<span>
<i class="kilo-pass-usage-paid-dot" />
Paid
</span>
<span hidden={model().bonus <= 0}>
<i class="kilo-pass-usage-bonus-dot" />
{language.t("profile.pass.bonus")}
</span>
</div>
</div>
)
}
const money = (value: number) => `$${value.toFixed(2)}`
const KiloPassCard: Component<{
pass?: KiloPassState | null
@@ -226,14 +170,16 @@ const KiloPassCard: Component<{
}).format(date)
}
return (
<Card class="provider-usage-card">
<div class="provider-usage-heading">
<Card>
<CardHeader>
<div>
<CardTitle icon={false}>Kilo</CardTitle>
<CardTitle icon={false} role="heading" aria-level={4}>
Kilo
</CardTitle>
<CardDescription>Kilo Pass</CardDescription>
</div>
<Tag>Kilo Gateway</Tag>
</div>
</CardHeader>
<Show
when={props.pass}
fallback={
@@ -246,10 +192,19 @@ const KiloPassCard: Component<{
>
{(pass) => (
<div class="provider-usage-resources">
<KiloPassUsage pass={pass()} />
<KiloPassMeter
used={pass().currentPeriodUsageUsd}
paid={pass().currentPeriodBaseCreditsUsd}
bonus={pass().currentPeriodBonusCreditsUsd}
label="This month's usage"
paidLabel="Paid"
bonusLabel={props.language.t("profile.pass.bonus")}
format={money}
aria-label="Kilo Pass monthly usage"
/>
<Show when={renewal()}>
{(date) => (
<div class="provider-usage-row-heading">
<div class="provider-usage-summary">
<span>{props.language.t("profile.pass.renews")}</span>
<strong>{date()}</strong>
</div>
@@ -292,13 +247,13 @@ export const ProviderUsageCards: Component<ProviderUsageCardsProps> = (props) =>
<Show
when={!props.loading && props.error}
fallback={
<div class="provider-usage-loading">
<div class="provider-usage-loading" role="status" aria-label={language.t("profile.usage.title")}>
<Spinner />
</div>
}
>
{(error) => (
<Card variant="warning">
<Card variant="warning" role="alert">
<CardDescription>{error()}</CardDescription>
</Card>
)}
@@ -1,4 +1,4 @@
/* Provider Usage Cards */
/* Provider Usage */
.provider-usage-section {
margin-top: 24px;
padding-top: 18px;
@@ -6,8 +6,7 @@
}
.provider-usage-section-heading,
.provider-usage-heading,
.provider-usage-row-heading {
.provider-usage-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -20,18 +19,17 @@
.provider-usage-section-heading h3 {
margin: 0;
font-size: var(--kilo-font-size-14);
font-weight: 650;
color: var(--vscode-foreground);
color: var(--text-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
.provider-usage-section-heading p,
.provider-usage-meta,
.provider-usage-notice {
margin: 3px 0 0;
color: var(--vscode-descriptionForeground);
font-size: var(--kilo-font-size-11);
line-height: 1.45;
color: var(--text-weak);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
.provider-usage-list,
@@ -41,129 +39,26 @@
gap: 10px;
}
.provider-usage-card {
position: relative;
overflow: hidden;
}
.provider-usage-heading [data-slot="card-title"] {
font-size: var(--kilo-font-size-13);
}
.provider-usage-row {
display: grid;
gap: 5px;
gap: 4px;
}
.provider-usage-row-heading {
.provider-usage-summary {
align-items: baseline;
color: var(--vscode-foreground);
font-size: var(--kilo-font-size-12);
color: var(--text-base);
font-size: var(--font-size-small);
}
.provider-usage-row-heading strong {
.provider-usage-summary strong {
text-align: right;
font-weight: 600;
}
.kilo-pass-usage-track {
position: relative;
height: 8px;
overflow: visible;
border-radius: 4px;
background: var(--border-weak-base);
}
.kilo-pass-usage-paid-background,
.kilo-pass-usage-bonus-background,
.kilo-pass-usage-paid-fill,
.kilo-pass-usage-bonus-fill {
position: absolute;
inset-block: 0;
}
.kilo-pass-usage-paid-background {
left: 0;
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 18%, transparent);
}
.kilo-pass-usage-bonus-background {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 18%, transparent);
}
.kilo-pass-usage-paid-fill {
left: 0;
border-radius: 4px 0 0 4px;
background: var(--vscode-editorWarning-foreground, #cca700);
}
.kilo-pass-usage-bonus-fill {
border-radius: 0 4px 4px 0;
background: var(--vscode-testing-iconPassed, #73c991);
}
.kilo-pass-usage-boundary {
position: absolute;
top: 100%;
width: 2px;
height: 5px;
margin-left: -1px;
background: var(--vscode-descriptionForeground);
}
.kilo-pass-usage-amounts {
position: relative;
height: 15px;
color: var(--vscode-editorWarning-foreground, #cca700);
font-size: var(--kilo-font-size-11);
font-weight: 600;
}
.kilo-pass-usage-amounts > span {
position: absolute;
transform: translateX(-50%);
}
.kilo-pass-usage-amounts .kilo-pass-usage-bonus-amount {
right: 0;
left: auto !important;
color: var(--vscode-testing-iconPassed, #73c991);
transform: none;
}
.kilo-pass-usage-legend,
.kilo-pass-usage-legend > span {
display: flex;
align-items: center;
}
.kilo-pass-usage-legend {
justify-content: space-between;
color: var(--vscode-descriptionForeground);
font-size: var(--kilo-font-size-11);
}
.kilo-pass-usage-legend > span {
gap: 6px;
}
.kilo-pass-usage-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.kilo-pass-usage-paid-dot {
background: var(--vscode-editorWarning-foreground, #cca700);
}
.kilo-pass-usage-bonus-dot {
background: var(--vscode-testing-iconPassed, #73c991);
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
.provider-usage-notice {
padding-left: 8px;
border-left: 2px solid var(--vscode-editorWarning-foreground);
border-left: 2px solid var(--icon-warning-base);
}
.provider-usage-loading {
@@ -172,14 +67,8 @@
place-items: center;
}
.provider-usage-loading [data-component="spinner"] {
width: 20px;
height: 20px;
}
@media (max-width: 360px) {
.provider-usage-section-heading,
.provider-usage-heading {
.provider-usage-section-heading {
align-items: stretch;
flex-direction: column;
}