Created
August 29, 2026 19:55
-
-
Save MeirionHughes/d87161f8b29b24c7fa96e56f571e7045 to your computer and use it in GitHub Desktop.
opencode tui plugin: shows normalised estimate of MONTHLY usage allowance of currently available models. scraps the go whensite, so future site changes may break this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** @jsxImportSource @opentui/solid */ | |
| import { useKeyboard } from "@opentui/solid"; | |
| import { TextAttributes } from "@opentui/core"; | |
| import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; | |
| import { join } from "node:path"; | |
| import { createSignal } from "solid-js"; | |
| import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; | |
| type Theme = TuiPluginApi["theme"]["current"]; | |
| type BaselineMode = "fixed" | "estimated"; | |
| type RequestPattern = { | |
| input: number; | |
| cache: number; | |
| output: number; | |
| }; | |
| type Row = { | |
| model: string; | |
| key: string; | |
| usage: number; | |
| rawMonthly: number; | |
| normalized: number; | |
| isNew?: boolean; | |
| changed?: boolean; | |
| multiplier?: number; | |
| }; | |
| type ParsedModel = { | |
| model: string; | |
| key: string; | |
| usage: number; | |
| inputP: number; | |
| outputP: number; | |
| cachedP: number; | |
| estimated: RequestPattern; | |
| multiplier: number; | |
| }; | |
| type SessionData = { | |
| fixed: Row[]; | |
| estimated: Row[]; | |
| }; | |
| const MEM_DIR = join(process.env.LOCALAPPDATA, "opencode"); | |
| const MEM_PATH = join(MEM_DIR, "go-usage-memory.json"); | |
| type StoredValue = number | "inf"; | |
| type StoredEntry = Partial<Record<BaselineMode, StoredValue>>; | |
| function loadKnown(): Map<string, StoredEntry> { | |
| try { | |
| if (!existsSync(MEM_PATH)) return new Map(); | |
| const parsed = JSON.parse(readFileSync(MEM_PATH, "utf8")); | |
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { | |
| const map = new Map<string, StoredEntry>(); | |
| const storedModels = | |
| parsed.models && typeof parsed.models === "object" ? parsed.models : parsed; | |
| for (const [k, v] of Object.entries(storedModels)) { | |
| if ((typeof v === "number" && Number.isFinite(v)) || v === "inf") { | |
| map.set(k, { fixed: v }); | |
| continue; | |
| } | |
| if (v && typeof v === "object" && !Array.isArray(v)) { | |
| const entry: StoredEntry = {}; | |
| for (const mode of ["fixed", "estimated"] as const) { | |
| const value = (v as Record<string, unknown>)[mode]; | |
| if ((typeof value === "number" && Number.isFinite(value)) || value === "inf") { | |
| entry[mode] = value; | |
| } | |
| } | |
| if (entry.fixed !== undefined || entry.estimated !== undefined) { | |
| map.set(k, entry); | |
| } | |
| } | |
| } | |
| return map; | |
| } | |
| } catch { | |
| /* ignore corrupt memory */ | |
| } | |
| return new Map(); | |
| } | |
| function saveKnown(values: Record<string, StoredEntry>): void { | |
| try { | |
| mkdirSync(MEM_DIR, { recursive: true }); | |
| writeFileSync(MEM_PATH, JSON.stringify(values, null, 2), "utf8"); | |
| } catch { | |
| /* ignore write failures */ | |
| } | |
| } | |
| const URL = "https://opencode.ai/docs/go/"; | |
| const GRAPH_URL = "https://opencode.ai/go"; | |
| const SCROLL_HEIGHT = 22; | |
| const SEP = "─".repeat(64); | |
| const MODEL_WIDTH = 17; | |
| const VALUE_WIDTH = 4; | |
| const GRAPH_WIDTH = 24; | |
| const LOG_MIN = 10; | |
| const LOG_MAX = 1_000_000; | |
| const DECADES: { label: string; color: string }[] = [ | |
| { label: "10s", color: "#00aa00" }, | |
| { label: "100s", color: "#00cccc" }, | |
| { label: "1k", color: "#cc66cc" }, | |
| { label: "10k", color: "#ccaa00" }, | |
| { label: "100k", color: "#cc4444" }, | |
| { label: "1M+", color: "#ffffff" }, | |
| ]; | |
| const DECADE_CELLS = [1, 2, 3, 4, 5, 6]; // cells per decade | |
| function decodeEntities(s: string): string { | |
| return s | |
| .replace(/ /g, " ") | |
| .replace(/—/g, "—") | |
| .replace(/–/g, "–") | |
| .replace(/&/g, "&") | |
| .replace(/�*8212;/g, "—") | |
| .replace(/�*8211;/g, "–") | |
| .replace(/�*38;/g, "&") | |
| .replace(/&[a-z]+;/gi, " "); | |
| } | |
| function stripTags(s: string): string { | |
| return s.replace(/<[^>]+>/g, " "); | |
| } | |
| function expandAssumptionNames(raw: string): string[] { | |
| const parts = raw.split("/").map((part) => part.trim()).filter(Boolean); | |
| if (parts.length <= 1) return parts; | |
| const first = parts[0]; | |
| const firstSpace = first.lastIndexOf(" "); | |
| const firstDigit = first.search(/\d/); | |
| const wordPrefix = firstSpace >= 0 ? first.slice(0, firstSpace + 1) : ""; | |
| const digitPrefix = firstDigit >= 0 ? first.slice(0, firstDigit) : first; | |
| return parts.map((part, i) => { | |
| if (i === 0) return part; | |
| if (/^\d/.test(part)) return `${digitPrefix}${part}`; | |
| if (/^[A-Za-z]?\d/.test(part)) return `${wordPrefix}${part}`; | |
| return part; | |
| }); | |
| } | |
| function parseAssumptions(html: string): Map<string, RequestPattern> { | |
| const assumptions = new Map<string, RequestPattern>(); | |
| const items = [...html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)]; | |
| for (const item of items) { | |
| const text = decodeEntities(stripTags(item[1])).replace(/\s+/g, " ").trim(); | |
| const match = text.match( | |
| /^(.+?)\s+[—-]\s*([\d,]+)\s+input,\s*([\d,]+)\s+cached,\s*([\d,]+)\s+output\s+tokens?\s+per\s+request$/i, | |
| ); | |
| if (!match) continue; | |
| const pattern = { | |
| input: Number(match[2].replace(/,/g, "")), | |
| cache: Number(match[3].replace(/,/g, "")), | |
| output: Number(match[4].replace(/,/g, "")), | |
| }; | |
| for (const name of expandAssumptionNames(match[1])) { | |
| assumptions.set(norm(name), pattern); | |
| } | |
| } | |
| return assumptions; | |
| } | |
| function parseBonuses(html: string): Map<string, number> { | |
| const bonuses = new Map<string, number>(); | |
| const pills = html.match(/<div data-slot="pills">([\s\S]*?)<\/div>/); | |
| if (!pills) return bonuses; | |
| const items = [ | |
| ...pills[1].matchAll(/data-model="([^"]+)"[^>]*>([\s\S]*?)(?=data-model="|$)/g), | |
| ]; | |
| for (const item of items) { | |
| const model = norm(item[1]); | |
| const inner = item[2]; | |
| const m = inner.match( | |
| /data-bonus>\s*([\d.]+)\s*x\s*(?:\([^)]*\)\s*)?usage/i, | |
| ); | |
| if (m) bonuses.set(model, Number(m[1])); | |
| } | |
| return bonuses; | |
| } | |
| function norm(s: string): string { | |
| return s.toLowerCase().replace(/[^a-z0-9]/g, ""); | |
| } | |
| function abbreviateModel(name: string): string { | |
| if (name.length <= 10) return name; | |
| const words = name.split(/\s+/).filter(Boolean); | |
| let pos = 0; | |
| const tokens: string[] = []; | |
| let abbrev = ""; | |
| for (const w of words) { | |
| const keepFull = pos <= 10 || /^\d/.test(w); | |
| if (keepFull) { | |
| if (abbrev) { | |
| tokens.push(abbrev); | |
| abbrev = ""; | |
| } | |
| tokens.push(w); | |
| } else { | |
| abbrev += w[0]; | |
| } | |
| pos += w.length + 1; | |
| } | |
| if (abbrev) tokens.push(abbrev); | |
| return tokens.join(" "); | |
| } | |
| function num(s: string | undefined): number | null { | |
| if (s == null) return null; | |
| const t = String(s).replace(/[$,\s]/g, ""); | |
| if (t === "-" || t === "") return null; | |
| const v = parseFloat(t); | |
| return Number.isFinite(v) ? v : null; | |
| } | |
| // Fixed assumed request composition (alternative to observed per-model patterns). | |
| const TOK_INPUT = 1000; | |
| const TOK_CACHE = 80000; | |
| const TOK_OUTPUT = 500; | |
| const FIXED_PATTERN: RequestPattern = { | |
| input: TOK_INPUT, | |
| cache: TOK_CACHE, | |
| output: TOK_OUTPUT, | |
| }; | |
| function formatValue(value: number): string { | |
| if (!Number.isFinite(value)) return "∞"; | |
| const rounded = Math.max(0, Math.round(value)); | |
| if (rounded >= 1_000_000) return `${Math.round(rounded / 1_000_000)}m`; | |
| if (rounded >= 1_000) { | |
| const thousands = rounded / 1_000; | |
| if (thousands < 10) return `${thousands.toFixed(1)}k`; | |
| const compactThousands = Math.round(thousands); | |
| return compactThousands >= 1_000 ? "1m" : `${compactThousands}k`; | |
| } | |
| return String(rounded); | |
| } | |
| function logPosition(value: number): number { | |
| const clamped = Math.min(LOG_MAX, Math.max(LOG_MIN, value)); | |
| const ratio = | |
| (Math.log10(clamped) - Math.log10(LOG_MIN)) / | |
| (Math.log10(LOG_MAX) - Math.log10(LOG_MIN)); | |
| return Math.round(ratio * (GRAPH_WIDTH - 1)); | |
| } | |
| function decadeFor(value: number): number { | |
| if (!Number.isFinite(value) || value >= LOG_MAX) return 5; | |
| return Math.min( | |
| 4, | |
| Math.max(0, Math.floor(Math.log10(Math.max(LOG_MIN, value))) - 1), | |
| ); | |
| } | |
| function parseTable(html: string): string[][] { | |
| const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)].map((m) => m[0]); | |
| const table = tables.find( | |
| (t) => /<th[^>]*>Usage/i.test(t) && /Cached Read/i.test(t), | |
| ); | |
| if (!table) throw new Error("usage table not found"); | |
| const rows = [...table.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[1]); | |
| return rows | |
| .filter((r) => !/<th/i.test(r)) | |
| .map((r) => | |
| [...r.matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g)].map((c) => | |
| stripTags(decodeEntities(c[1])).trim(), | |
| ), | |
| ); | |
| } | |
| async function buildRows(): Promise<{ models: ParsedModel[]; error?: string }> { | |
| let html: string; | |
| try { | |
| const res = await fetch(URL); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| html = await res.text(); | |
| } catch (e) { | |
| return { models: [], error: String(e) }; | |
| } | |
| try { | |
| const rows = parseTable(html); | |
| const assumptions = parseAssumptions(html); | |
| let bonuses: Map<string, number> = new Map(); | |
| try { | |
| const gres = await fetch(GRAPH_URL); | |
| if (gres.ok) bonuses = parseBonuses(await gres.text()); | |
| } catch { | |
| /* graph bonuses are optional */ | |
| } | |
| const models: ParsedModel[] = []; | |
| const seen = new Set<string>(); | |
| for (const cells of rows) { | |
| const model = cells[0]; | |
| if (!model) continue; | |
| const base = norm(model.replace(/\(.*?\)/g, "").trim()); | |
| if (seen.has(base)) continue; // dedupe (e.g. Grok 4.6 x2) | |
| seen.add(base); | |
| const inputP = num(cells[1]); | |
| const outputP = num(cells[2]); | |
| const cachedP = num(cells[3]); | |
| const usage = num(cells[5]); | |
| if (inputP == null || outputP == null || cachedP == null || usage == null) | |
| continue; | |
| models.push({ | |
| model: model.replace(/\(.*?\)/g, "").trim(), | |
| key: base, | |
| usage, | |
| inputP, | |
| outputP, | |
| cachedP, | |
| estimated: assumptions.get(base) ?? FIXED_PATTERN, | |
| multiplier: bonuses.get(base) ?? 1, | |
| }); | |
| } | |
| return { models }; | |
| } catch (e) { | |
| return { models: [], error: String(e) }; | |
| } | |
| } | |
| function calculateRows(models: ParsedModel[], mode: BaselineMode): Row[] { | |
| const rows = models.map((model) => { | |
| const pattern = mode === "estimated" ? model.estimated : FIXED_PATTERN; | |
| const costPerRequest = | |
| (pattern.input / 1e6) * model.inputP + | |
| (pattern.cache / 1e6) * model.cachedP + | |
| (pattern.output / 1e6) * model.outputP; | |
| const rawMonthly = | |
| costPerRequest === 0 ? Infinity : (model.usage / costPerRequest) * model.multiplier; | |
| const factor = model.usage / 60; // $60 => 1.0; $30 => 0.5; $15 => 0.25 | |
| const normalized = costPerRequest === 0 ? Infinity : rawMonthly * factor; | |
| return { | |
| model: model.model, | |
| key: model.key, | |
| usage: model.usage, | |
| rawMonthly, | |
| normalized, | |
| multiplier: model.multiplier, | |
| }; | |
| }); | |
| rows.sort((a, b) => b.normalized - a.normalized); | |
| return rows; | |
| } | |
| function markRows( | |
| rows: Row[], | |
| known: Map<string, StoredEntry>, | |
| mode: BaselineMode, | |
| ): void { | |
| for (const row of rows) { | |
| const previous = known.get(row.key)?.[mode]; | |
| row.isNew = known.get(row.key) === undefined; | |
| row.changed = | |
| previous !== undefined && | |
| (row.normalized === Infinity | |
| ? previous !== "inf" | |
| : previous === "inf" || Math.round(previous) !== Math.round(row.normalized)); | |
| } | |
| } | |
| function storedValue(value: number): StoredValue { | |
| return Number.isFinite(value) ? value : "inf"; | |
| } | |
| function GoUsageView(props: { | |
| theme: Theme; | |
| rows?: Row[]; | |
| estimatedRows?: Row[]; | |
| loading?: boolean; | |
| error?: string; | |
| }) { | |
| const theme = props.theme; | |
| const [mode, setMode] = createSignal<BaselineMode>("fixed"); | |
| const displayedRows = () => | |
| mode() === "estimated" ? props.estimatedRows ?? props.rows : props.rows; | |
| let scroll: { scrollHeight: number; height: number; scrollBy(d: number): void } | undefined; | |
| useKeyboard((evt: any) => { | |
| if (String(evt.name).toLowerCase() === "m") { | |
| setMode(mode() === "fixed" ? "estimated" : "fixed"); | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| return; | |
| } | |
| if (!scroll) return; | |
| if (scroll.scrollHeight <= scroll.height) return; | |
| if (evt.name === "up") { | |
| scroll.scrollBy(-1); | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } else if (evt.name === "down") { | |
| scroll.scrollBy(1); | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } else if (evt.name === "pageup") { | |
| scroll.scrollBy(-10); | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } else if (evt.name === "pagedown") { | |
| scroll.scrollBy(10); | |
| evt.preventDefault(); | |
| evt.stopPropagation(); | |
| } | |
| }); | |
| return ( | |
| <box | |
| flexDirection="column" | |
| width="100%" | |
| paddingTop={1} | |
| paddingBottom={1} | |
| paddingLeft={2} | |
| paddingRight={2} | |
| gap={0} | |
| > | |
| <box flexDirection="row" width="100%" justifyContent="space-between"> | |
| <text fg={theme.primary}> | |
| <b>Go usages limits</b>{" "} | |
| <b> | |
| <span style={{ fg: theme.textMuted }}> | |
| · {mode() === "fixed" ? "$60 normalized baseline" : "site usage baseline"} | |
| </span> | |
| </b> | |
| </text> | |
| <text fg={theme.textMuted}> ↑/↓ · esc</text> | |
| </box> | |
| <text fg={theme.borderSubtle} wrapMode="none"> | |
| {SEP} | |
| </text> | |
| {props.loading ? ( | |
| <text fg={theme.textMuted} wrapMode="none"> | |
| Loading OpenCode Go usage limits from opencode.ai… | |
| </text> | |
| ) : props.error ? ( | |
| <text fg={theme.error} wrapMode="none"> | |
| Failed to load: {props.error} | |
| </text> | |
| ) : ( | |
| <scrollbox | |
| height={SCROLL_HEIGHT} | |
| scrollY | |
| scrollbarOptions={{ trackOptions: { backgroundColor: theme.borderSubtle } }} | |
| viewportOptions={{ paddingRight: 2 }} | |
| ref={(r: any) => { | |
| scroll = r; | |
| }} | |
| > | |
| <box flexDirection="column" width="100%" gap={0}> | |
| {(displayedRows() ?? []).map((r) => { | |
| const position = logPosition(r.normalized); | |
| const markerColor = DECADES[decadeFor(r.normalized)].color; | |
| const basePosition = | |
| r.multiplier && r.multiplier !== 1 | |
| ? logPosition(r.normalized / r.multiplier) | |
| : position; | |
| const graph = Array.from({ length: GRAPH_WIDTH }, (_, i) => { | |
| if (i > position) return { char: "─", color: theme.borderSubtle }; | |
| if (i <= basePosition) | |
| return { char: "█", color: markerColor }; | |
| return { char: "▒", color: markerColor }; | |
| }); | |
| const abbreviated = | |
| r.model.length > 10 ? abbreviateModel(r.model) : r.model; | |
| const label = | |
| abbreviated.length > MODEL_WIDTH | |
| ? abbreviated.slice(0, MODEL_WIDTH - 1) + "…" | |
| : abbreviated; | |
| const right = formatValue(r.normalized).padStart(VALUE_WIDTH); | |
| return ( | |
| <text fg={theme.text} wrapMode="none"> | |
| {label.padEnd(MODEL_WIDTH)}{" "} | |
| {graph.map((cell) => ( | |
| <b> | |
| <span style={{ fg: cell.color }}>{cell.char}</span> | |
| </b> | |
| ))} | |
| {r.isNew ? ( | |
| <> | |
| {" "} | |
| <b> | |
| <span style={{ fg: "#33ff66" }}>new</span> | |
| </b> | |
| </> | |
| ) : r.changed ? ( | |
| <> | |
| {" "} | |
| <b> | |
| <span style={{ fg: "#ffcc33" }}>changed</span> | |
| </b> | |
| </> | |
| ) : null} | |
| {r.multiplier && r.multiplier !== 1 ? ( | |
| <> | |
| {" "} | |
| <b> | |
| <span style={{ fg: "#00cccc" }}>{r.multiplier}x</span> | |
| </b> | |
| </> | |
| ) : null} | |
| {" "} | |
| <b> | |
| <span style={{ fg: theme.textMuted }}>{right}</span> | |
| </b> | |
| </text> | |
| ); | |
| })} | |
| </box> | |
| </scrollbox> | |
| )} | |
| <text fg={theme.textMuted} wrapMode="none"> | |
| {DECADES.map((d, i) => ( | |
| <> | |
| <b> | |
| <span style={{ fg: d.color }}>{"█"}</span> | |
| </b> | |
| <b> | |
| <span style={{ fg: theme.textMuted }}>{` ${d.label}`}</span> | |
| </b> | |
| {i < DECADES.length - 1 ? " " : ""} | |
| </> | |
| ))} | |
| </text> | |
| <text | |
| fg={theme.borderSubtle} | |
| wrapMode="none" | |
| style={{ attributes: TextAttributes.ITALIC }} | |
| > | |
| {mode() === "fixed" | |
| ? "1000 in / 80000 cache / 500 out · m = site estimates" | |
| : "site estimates per model · m = fixed baseline"} | |
| </text> | |
| </box> | |
| ); | |
| } | |
| let inFlight = false; | |
| let sessionCache: SessionData | null = null; | |
| async function showGoUsage(api: TuiPluginApi): Promise<void> { | |
| if (inFlight) return; | |
| if (sessionCache) { | |
| api.ui.dialog.setSize("xlarge"); | |
| api.ui.dialog.replace(() => ( | |
| <GoUsageView | |
| theme={api.theme.current} | |
| rows={sessionCache.fixed} | |
| estimatedRows={sessionCache.estimated} | |
| /> | |
| )); | |
| return; | |
| } | |
| inFlight = true; | |
| try { | |
| api.ui.dialog.setSize("xlarge"); | |
| api.ui.dialog.replace(() => <GoUsageView theme={api.theme.current} loading />); | |
| const { models, error } = await buildRows(); | |
| if (api.lifecycle.signal.aborted) return; | |
| if (error || models.length === 0) { | |
| api.ui.dialog.replace(() => ( | |
| <GoUsageView theme={api.theme.current} error={error ?? "no models parsed"} /> | |
| )); | |
| return; | |
| } | |
| const known = loadKnown(); | |
| const fixed = calculateRows(models, "fixed"); | |
| const estimated = calculateRows(models, "estimated"); | |
| markRows(fixed, known, "fixed"); | |
| markRows(estimated, known, "estimated"); | |
| const store: Record<string, StoredEntry> = {}; | |
| for (const model of models) { | |
| const fixedRow = fixed.find((row) => row.key === model.key); | |
| const estimatedRow = estimated.find((row) => row.key === model.key); | |
| if (fixedRow && estimatedRow) { | |
| store[model.key] = { | |
| fixed: storedValue(fixedRow.normalized), | |
| estimated: storedValue(estimatedRow.normalized), | |
| }; | |
| } | |
| } | |
| saveKnown(store); | |
| sessionCache = { fixed, estimated }; | |
| api.ui.dialog.replace(() => ( | |
| <GoUsageView theme={api.theme.current} rows={fixed} estimatedRows={estimated} /> | |
| )); | |
| } catch (e) { | |
| api.ui.toast({ | |
| title: "Go usages limits", | |
| message: String(e), | |
| variant: "error", | |
| }); | |
| } finally { | |
| inFlight = false; | |
| } | |
| } | |
| const tui: TuiPlugin = async (api) => { | |
| api.keymap.registerLayer({ | |
| commands: [ | |
| { | |
| name: "go-usage-limits.show", | |
| title: "Go usages limits", | |
| category: "Plugin", | |
| namespace: "palette", | |
| slashName: "go-usage-limits", | |
| run: () => { | |
| void showGoUsage(api); | |
| }, | |
| }, | |
| ], | |
| }); | |
| }; | |
| const plugin = { id: "go-usage-limits", tui }; | |
| export default plugin; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment