133 lines
4.9 KiB
TypeScript
133 lines
4.9 KiB
TypeScript
import { Plugin } from "@opencode/plugin/tui";
|
|
import { createResource, createSignal, For, Show } from "solid-js";
|
|
import type { QuotaStatus, QuotaWindow } from "./quota.js";
|
|
import { QuotaRpc } from "./quota-rpc.js";
|
|
|
|
function percent(value: number) {
|
|
return value < 0.1 && value > 0 ? "<0.1" : value.toFixed(value < 10 ? 1 : 0);
|
|
}
|
|
|
|
function duration(seconds: number) {
|
|
if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
|
|
if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
|
|
return `${Math.round(seconds / 60)}m`;
|
|
}
|
|
|
|
function resetTiming(resetsAt: number, now: number) {
|
|
if (!Number.isFinite(resetsAt) || resetsAt <= 0 || !Number.isFinite(now)) return "unavailable";
|
|
const remaining = resetsAt - now;
|
|
if (remaining <= 0) return "due";
|
|
if (remaining < 60_000) return "<1m";
|
|
|
|
const minutes = Math.floor(remaining / 60_000);
|
|
const days = Math.floor(minutes / 1_440);
|
|
if (days > 0) {
|
|
const hours = Math.floor((minutes % 1_440) / 60);
|
|
return `${days}d${hours > 0 ? ` ${hours}h` : ""}`;
|
|
}
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours > 0) {
|
|
const rest = minutes % 60;
|
|
return `${hours}h${rest > 0 ? ` ${rest}m` : ""}`;
|
|
}
|
|
return `${minutes}m`;
|
|
}
|
|
|
|
export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) {
|
|
const label = window.label
|
|
? `${window.label} ${duration(window.seconds)}`
|
|
: duration(window.seconds);
|
|
return `${label}: ${percent(window.remaining)}% · ${resetTiming(window.resetsAt, now)}`;
|
|
}
|
|
|
|
export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) {
|
|
try {
|
|
return await load();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default Plugin.define({
|
|
id: "stanp-opencode.quota-tui",
|
|
setup(context) {
|
|
const quota = context.client.rpc(QuotaRpc);
|
|
const [revision, setRevision] = createSignal(0);
|
|
const retry = setInterval(() => {
|
|
setRevision((value) => value + 1);
|
|
}, 60_000);
|
|
const unsubscribe = quota.events.on("updated", () => {
|
|
setRevision((value) => value + 1);
|
|
});
|
|
|
|
const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => {
|
|
const [statuses] = createResource(
|
|
() => (props.visible?.() === false ? false : `${props.instance}:${revision()}`),
|
|
async () => {
|
|
const [chatgpt, claude] = await Promise.all([
|
|
loadQuotaStatus(() => quota.status({})),
|
|
loadQuotaStatus(() => quota.claude({})),
|
|
]);
|
|
return chatgpt || claude ? { chatgpt, claude } : null;
|
|
},
|
|
);
|
|
const Section = (section: { title: string; status: QuotaStatus | null }) => (
|
|
<Show when={section.status}>
|
|
{(status: () => QuotaStatus) => (
|
|
<>
|
|
<text fg={context.theme.text.default}>
|
|
<b>
|
|
{section.title} quota ({status().plan})
|
|
</b>
|
|
</text>
|
|
<For each={status().windows}>
|
|
{(window) => (
|
|
<text fg={context.theme.text.muted}>
|
|
{formatQuotaWindow(window)}
|
|
</text>
|
|
)}
|
|
</For>
|
|
</>
|
|
)}
|
|
</Show>
|
|
);
|
|
const Content = () => (
|
|
<box flexDirection="column">
|
|
<Section title="ChatGPT" status={statuses()!.chatgpt} />
|
|
<Section title="Claude" status={statuses()!.claude} />
|
|
</box>
|
|
);
|
|
return (
|
|
<Show when={statuses()}>
|
|
<Show when={props.centered} fallback={<Content />}>
|
|
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
|
<Content />
|
|
</box>
|
|
</Show>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
const removeHome = context.ui.slot({
|
|
after: "prompt.footer",
|
|
render: () => (
|
|
<Slot
|
|
instance="home"
|
|
centered
|
|
visible={() => context.ui.router.current().type === "home"}
|
|
/>
|
|
),
|
|
});
|
|
const removeSidebar = context.ui.slot({
|
|
append: "sidebar.footer",
|
|
render: (input) => <Slot instance={input.sessionID} />,
|
|
});
|
|
return () => {
|
|
removeHome();
|
|
removeSidebar();
|
|
unsubscribe();
|
|
clearInterval(retry);
|
|
};
|
|
},
|
|
});
|