feat(ui): add context meter indicator
Replace duplicated Used/Avail pills with a shared ContextMeter component and add a small filled context usage indicator for quick scanning.
This commit is contained in:
123
packages/ui/src/components/context-meter.tsx
Normal file
123
packages/ui/src/components/context-meter.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
|
interface ContextMeterProps {
|
||||||
|
usedTokens: number
|
||||||
|
availableTokens: number | null
|
||||||
|
formatTokens: (value: number) => string
|
||||||
|
usedLabel: string
|
||||||
|
availableLabel: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFillColor(percent: number): string {
|
||||||
|
if (percent >= 0.8) return "var(--status-error)"
|
||||||
|
if (percent >= 0.6) return "var(--status-warning)"
|
||||||
|
return "var(--status-success)"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMeter: Component<ContextMeterProps> = (props) => {
|
||||||
|
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
|
||||||
|
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
|
||||||
|
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
|
||||||
|
|
||||||
|
const percent = () => {
|
||||||
|
const usedValue = used()
|
||||||
|
const availableValue = available()
|
||||||
|
if (availableValue === null || availableValue <= 0) return null
|
||||||
|
|
||||||
|
// Heuristic: if available >= used, treat it like a capacity/limit.
|
||||||
|
// Otherwise treat it like remaining tokens.
|
||||||
|
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
|
||||||
|
return clamp(ratio, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillColor = () => {
|
||||||
|
const value = percent()
|
||||||
|
if (value === null) return "var(--border-base)"
|
||||||
|
return resolveFillColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentLabel = () => {
|
||||||
|
const value = percent()
|
||||||
|
if (value === null) return "--"
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClass =
|
||||||
|
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
|
||||||
|
|
||||||
|
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||||
|
const rad = (angleDeg * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
x: cx + r * Math.cos(rad),
|
||||||
|
y: cy + r * Math.sin(rad),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||||
|
const start = polarToCartesian(cx, cy, r, startAngle)
|
||||||
|
const end = polarToCartesian(cx, cy, r, endAngle)
|
||||||
|
const delta = ((endAngle - startAngle) % 360 + 360) % 360
|
||||||
|
const largeArc = delta > 180 ? 1 : 0
|
||||||
|
|
||||||
|
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
const circle = () => {
|
||||||
|
const value = percent()
|
||||||
|
const size = 22
|
||||||
|
const r = 9
|
||||||
|
const cx = 11
|
||||||
|
const cy = 11
|
||||||
|
const progress = value === null ? 0 : value
|
||||||
|
const startAngle = -90
|
||||||
|
const endAngle = startAngle + progress * 360
|
||||||
|
const isFull = progress >= 0.999
|
||||||
|
const hasFill = progress > 0.001
|
||||||
|
|
||||||
|
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
>
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
|
||||||
|
{isFull ? (
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
|
||||||
|
) : sectorPath ? (
|
||||||
|
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipText = () => `Context Used: ${percentLabel()}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="inline-flex items-center gap-2" title={tooltipText()}>
|
||||||
|
{circle()}
|
||||||
|
<div class={containerClass}>
|
||||||
|
<span class={LABEL_CLASS}>{props.usedLabel}</span>
|
||||||
|
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
|
||||||
|
<span class="text-muted">/</span>
|
||||||
|
<span class={LABEL_CLASS}>{props.availableLabel}</span>
|
||||||
|
<span class="font-semibold text-primary tabular-nums">
|
||||||
|
{available() !== null ? props.formatTokens(available() as number) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContextMeter
|
||||||
@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
|||||||
import PermissionApprovalModal from "../permission-approval-modal"
|
import PermissionApprovalModal from "../permission-approval-modal"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
|
import ContextMeter from "../context-meter"
|
||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
@@ -349,16 +350,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
measureDrawerHost,
|
measureDrawerHost,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
|
||||||
|
|
||||||
|
|
||||||
const formattedAvailableTokens = () => {
|
|
||||||
const avail = tokenStats().avail
|
|
||||||
if (typeof avail === "number") {
|
|
||||||
return formatTokenTotal(avail)
|
|
||||||
}
|
|
||||||
return "--"
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (leftPinned()) {
|
if (leftPinned()) {
|
||||||
@@ -661,20 +652,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<ContextMeter
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
usedTokens={tokenStats().used}
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
availableTokens={tokenStats().avail}
|
||||||
</span>
|
formatTokens={formatTokenTotal}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
|
||||||
{t("instanceShell.metrics.availableLabel")}
|
|
||||||
</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -693,18 +679,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<Show when={!showingInfoView()}>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<ContextMeter
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
usedTokens={tokenStats().used}
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
availableTokens={tokenStats().avail}
|
||||||
</span>
|
formatTokens={formatTokenTotal}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
</div>
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
/>
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
|
||||||
{t("instanceShell.metrics.availableLabel")}
|
|
||||||
</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center session-header-hints">
|
<div class="ml-auto flex items-center session-header-hints">
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import ContextMeter from "./context-meter"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
|
||||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
|
||||||
|
|
||||||
interface MessageListHeaderProps {
|
interface MessageListHeaderProps {
|
||||||
usedTokens: number
|
usedTokens: number
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||||
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
|
|
||||||
<div class="connection-status-text connection-status-info">
|
<div class="connection-status-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<ContextMeter
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
usedTokens={props.usedTokens}
|
||||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
||||||
</div>
|
formatTokens={props.formatTokens}
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
||||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user