diff --git a/packages/ui/src/components/context-meter.tsx b/packages/ui/src/components/context-meter.tsx new file mode 100644 index 00000000..cd375269 --- /dev/null +++ b/packages/ui/src/components/context-meter.tsx @@ -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 = (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 ( + + ) + } + + const tooltipText = () => `Context Used: ${percentLabel()}` + + return ( +
+ {circle()} +
+ {props.usedLabel} + {props.formatTokens(used())} + / + {props.availableLabel} + + {available() !== null ? props.formatTokens(available() as number) : "--"} + +
+
+ ) +} + +export default ContextMeter diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ff5d0408..5238159b 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" +import ContextMeter from "../context-meter" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" @@ -349,16 +350,6 @@ const InstanceShell2: Component = (props) => { measureDrawerHost, }) - const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) - - - const formattedAvailableTokens = () => { - const avail = tokenStats().avail - if (typeof avail === "number") { - return formatTokenTotal(avail) - } - return "--" - } const renderLeftPanel = () => { if (leftPinned()) { @@ -661,20 +652,15 @@ const InstanceShell2: Component = (props) => { -
-
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} +
+
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
-
} > @@ -693,18 +679,13 @@ const InstanceShell2: Component = (props) => { -
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} -
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
+
diff --git a/packages/ui/src/components/message-list-header.tsx b/packages/ui/src/components/message-list-header.tsx index 2a7bc6ad..75513971 100644 --- a/packages/ui/src/components/message-list-header.tsx +++ b/packages/ui/src/components/message-list-header.tsx @@ -1,10 +1,8 @@ import { Show } from "solid-js" import Kbd from "./kbd" +import ContextMeter from "./context-meter" 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 { usedTokens: number @@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) { const { t } = useI18n() const hasAvailableTokens = () => typeof props.availableTokens === "number" - const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--") return (
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
-
- {t("messageListHeader.metrics.usedLabel")} - {props.formatTokens(props.usedTokens)} -
-
- {t("messageListHeader.metrics.availableLabel")} - {hasAvailableTokens() ? availableDisplay() : "--"} -
+