diff --git a/README.md b/README.md index 77738d2..79dcb1d 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,6 @@ opencode Plugin: shows DeepSeek API balance in the TUI sidebar. -## Status - -Wave P13, Beta. - ## Installation ```bash @@ -14,7 +10,7 @@ bun install @four-bytes/four-opencode-deepseek-meter ## Configuration -Requires `DEEPSEEK_API_KEY` environment variable. +Reads the DeepSeek API key from the opencode provider configuration — no `DEEPSEEK_API_KEY` env var needed. Load in opencode via directory path (dual server + tui plugin): @@ -28,15 +24,14 @@ Load in opencode via directory path (dual server + tui plugin): ## Usage -Start opencode and you'll see `DEEPSEEK` in the right-hand sidebar showing your current balance. Polls every 60 seconds. +Start opencode with a DeepSeek provider configured. You'll see `DEEPSEEK` in the right-hand sidebar showing your current balance. Polls every 60 seconds. ## Build ```bash -bun run build # server plugin -bun run build:tui # TUI sidebar component +bun run build ``` ## License -Apache-2.0 — see [LICENSE](../LICENSE) +Apache-2.0 diff --git a/bun.lock b/bun.lock index 09db3e3..5fd684a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,9 @@ "": { "name": "@four-bytes/four-opencode-deepseek-meter", "dependencies": { - "@opencode-ai/plugin": "1.16.2", - "@opentui/core": "0.3.2", - "@opentui/solid": "0.3.2", - "solid-js": "1.9.13", + "@opencode-ai/plugin": "^1.4.3", + "@opentui/solid": "^0.2.2", + "solid-js": "^1.9.12", }, "devDependencies": { "@types/bun": "^1.3.8", @@ -117,7 +116,7 @@ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-q8xqMhW1jlJVzos+A5+FXRquH01j1ZHmrNi/9++W1Ebz3LQYn+8Z8j7rcV/meIiDuo9nyRHigQQT+cy9xV4N2g=="], - "@opentui/solid": ["@opentui/solid@0.3.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Yff0gSwIY/o0XeMciYeAUkQtea8bWzR0UjjVglmcBe13hWuJZt/GfjbDMdNNQ8zCrLubLEh04an5fYXCd7NMYQ=="], + "@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -297,6 +296,8 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opentui/solid/@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -304,5 +305,17 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@opentui/solid/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="], + + "@opentui/solid/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="], + + "@opentui/solid/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="], + + "@opentui/solid/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="], + + "@opentui/solid/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="], + + "@opentui/solid/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="], } } diff --git a/package.json b/package.json index e1dd45a..e7f470c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "type": "module", "main": "dist/four-opencode-deepseek-meter.js", + "types": "dist/four-opencode-deepseek-meter.d.ts", "scripts": { "build": "NODE_ENV=production bun run scripts/build.ts", "dev": "bun run scripts/build.ts", @@ -19,28 +20,19 @@ "balance" ], "exports": { - ".": { - "import": "./dist/four-opencode-deepseek-meter.js" - }, "./server": { - "import": "./dist/four-opencode-deepseek-meter.js", - "config": { - "enabled": true - } + "types": "./dist/four-opencode-deepseek-meter.d.ts", + "import": "./dist/four-opencode-deepseek-meter.js" }, "./tui": { - "import": "./dist/four-opencode-deepseek-meter-tui.jsx", - "config": { - "enabled": true, - "sidebar": true - } + "types": "./dist/tui.d.ts", + "import": "./dist/tui.jsx" } }, "dependencies": { - "@opencode-ai/plugin": "1.16.2", - "@opentui/core": "0.3.2", - "@opentui/solid": "0.3.2", - "solid-js": "1.9.13" + "@opencode-ai/plugin": "^1.4.3", + "@opentui/solid": "^0.2.2", + "solid-js": "^1.9.12" }, "devDependencies": { "@types/bun": "^1.3.8", diff --git a/scripts/build.ts b/scripts/build.ts index 6fe89a1..4979790 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,3 +1,13 @@ +import { rmSync } from "node:fs"; + +// Prune dist before building +rmSync("dist", { recursive: true, force: true }); + +// 1. TUI build: tsc strips types, preserves JSX → .jsx + .d.ts +// (also compiles server.ts, but Bun.build overrides it below) +await Bun.$`bunx tsc`; + +// 2. Server build: Bun bundler for optimized output const server = await Bun.build({ entrypoints: ["src/four-opencode-deepseek-meter.ts"], outdir: "dist", @@ -6,21 +16,20 @@ const server = await Bun.build({ minify: process.env.NODE_ENV === "production", }); -const tui = await Bun.build({ - entrypoints: ["src/tui.tsx"], - outdir: "dist", - target: "bun", - naming: "four-opencode-deepseek-meter-tui.jsx", - external: ["@opencode-ai/*", "@opentui/*", "solid-js"], - minify: process.env.NODE_ENV === "production", -}); - -if (!server.success || !tui.success) { - for (const log of [...server.logs, ...tui.logs]) console.error(log); +if (!server.success) { + for (const log of server.logs) console.error(log); process.exit(1); } -for (const out of [...server.outputs, ...tui.outputs]) { +// Report outputs +for (const out of server.outputs) { console.log(` ${out.path.padEnd(46)} ${(out.size / 1024).toFixed(2)} KB`); } -console.log(`\n✅ Built 2 files`); +for (const f of ["dist/tui.jsx", "dist/tui.d.ts", "dist/four-opencode-deepseek-meter.d.ts"]) { + const file = Bun.file(f); + if (await file.exists()) { + const size = (await file.arrayBuffer()).byteLength; + console.log(` ${f.padEnd(46)} ${(size / 1024).toFixed(2)} KB`); + } +} +console.log(`\n✅ Built (tsc TUI + Bun server)`); diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..fa9a62c --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import plugin from "./four-opencode-deepseek-meter.js"; + +describe("four-opencode-deepseek-meter", () => { + test("exports id", () => { + expect(plugin.id).toBe("four-opencode-deepseek-meter"); + }); + + test("exports server function", () => { + expect(typeof plugin.server).toBe("function"); + }); + + test("server returns expected shape", async () => { + const result = await (plugin.server as () => Promise)(); + expect(result).toBeDefined(); + }); +}); diff --git a/src/tui.tsx b/src/tui.tsx index 12b8df3..93bdc92 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,193 +1,120 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, createMemo, onCleanup, onMount, Show } from "solid-js"; -import type { TuiPlugin } from "@opencode-ai/plugin/tui"; +import { createEffect, createMemo, createSignal, onMount } from "solid-js"; +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; const BALANCE_URL = "https://api.deepseek.com/user/balance"; -const REFRESH_INTERVAL_MS = 30_000; +const MIN_REFRESH_INTERVAL_MS = 10_000; const SIDEBAR_ORDER = 55; -interface Palette { - subtle: string; - text: string; - muted: string; - accent: string; - warning: string; +interface BalanceResponse { + balance_infos: { currency: string; total_balance: string; topped_up_balance: string }[]; } -const getPalette = (theme: Record): Palette => { - const get = (name: string, fallback: string): string => { - const value = theme[name]; - if (typeof value === "string") return value; - return fallback; - }; - - return { - subtle: get("borderSubtle", "#2a2a2a"), - text: get("text", "#f0f0f0"), - muted: get("textMuted", "#a5a5a5"), - accent: get("primary", "#5f87ff"), - warning: get("warning", "#d7a94b"), - }; -}; - -interface BalanceInfo { - currency: string; - total_balance: string; - granted_balance: string; - topped_up_balance: string; +function isDeepSeek(p: any): boolean { + const id = String(p.id ?? "").toLowerCase(); + if (id.includes("deepseek")) return true; + const bu = String(p.options?.baseURL ?? p.options?.baseUrl ?? "").toLowerCase(); + return bu.includes("deepseek.com") || bu.includes("deepseek.ai"); } -interface BalanceResponse { - is_available: boolean; - balance_infos: BalanceInfo[]; +function findDeepSeekKey(api: TuiPluginApi): string | undefined { + const provs = api.state?.provider ?? []; + for (const p of provs) { + if (!isDeepSeek(p)) continue; + if (p.key) return p.key; + if (typeof p.options?.apiKey === "string" && p.options.apiKey) return p.options.apiKey; + } + return undefined; } -const BALANCE_GREEN = "#22c55e"; -const BALANCE_YELLOW = "#eab308"; -const BALANCE_ORANGE = "#f97316"; -const BALANCE_RED = "#ef4444"; +function DeepseekView(props: { api: TuiPluginApi; session_id?: string }) { + const theme = () => props.api.theme.current; -const BalanceRow = (props: { palette: Palette; label: string; value: string; color?: string }) => ( - - - {props.label} - - - {props.value} - - -); + const [status, setStatus] = createSignal("loading..."); + const [statusColor, setStatusColor] = createSignal(theme().textMuted); -const DeepseekBalance = (props: { palette: Palette }) => { - const [balance, setBalance] = createSignal(null); - const [isAvailable, setIsAvailable] = createSignal(true); - const [loading, setLoading] = createSignal(true); - const [errorMsg, setErrorMsg] = createSignal(""); + let lastFetch = 0; const fetchBalance = async () => { - const apiKey = process.env.DEEPSEEK_API_KEY; + const apiKey = findDeepSeekKey(props.api); if (!apiKey) { - setErrorMsg("DEEPSEEK_API_KEY not set"); - setLoading(false); + setStatus("no key"); + setStatusColor(theme().warning); return; } try { const res = await fetch(BALANCE_URL, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, }); if (!res.ok) { - setErrorMsg(`DeepSeek API ${res.status}`); - setLoading(false); + setStatus(`API ${res.status}`); + setStatusColor(theme().error); return; } const data = (await res.json()) as BalanceResponse; const info = data.balance_infos?.[0]; if (info) { - setBalance(info); - setIsAvailable(data.is_available); - setErrorMsg(""); + const raw = parseFloat(info.topped_up_balance || info.total_balance); + const amount = raw.toFixed(2); + setStatus(`${info.currency} ${amount}`); + setStatusColor(raw < 3.0 ? theme().warning : theme().success); } else { - setErrorMsg("No balance data"); + setStatus("no data"); + setStatusColor(theme().warning); } } catch { - if (!balance()) { - setErrorMsg("DeepSeek API unreachable"); - } - } finally { - setLoading(false); + setStatus("unreachable"); + setStatusColor(theme().error); } }; - const totalNum = createMemo(() => { - const b = balance(); - if (!b) return 0; - return parseFloat(b.total_balance) || 0; - }); - - const balanceColor = createMemo(() => { - const n = totalNum(); - if (n <= 0) return BALANCE_RED; - if (n < 1) return BALANCE_ORANGE; - if (n < 10) return BALANCE_YELLOW; - return BALANCE_GREEN; - }); - - const formatBalance = (value: string) => { - const n = parseFloat(value); - if (isNaN(n)) return value; - return n.toFixed(2); + const throttledFetch = () => { + const now = Date.now(); + if (now - lastFetch < MIN_REFRESH_INTERVAL_MS) return; + lastFetch = now; + void fetchBalance(); }; - const toppedUp = createMemo(() => { - const b = balance(); - if (!b) return ""; - const amount = b.topped_up_balance || b.total_balance; - return `${b.currency} ${formatBalance(amount)}`; + const lastMsg = createMemo(() => { + if (!props.session_id) return null; + const msgs = props.api.state?.session?.messages(props.session_id); + return msgs[msgs.length - 1]; }); - const granted = createMemo(() => { - const b = balance(); - if (!b?.granted_balance) return ""; - return `${b.currency} ${formatBalance(b.granted_balance)}`; + createEffect(() => { + const last = lastMsg(); + if (last?.role === "assistant") throttledFetch(); }); onMount(() => { void fetchBalance(); - const timer = setInterval(() => void fetchBalance(), REFRESH_INTERVAL_MS); - onCleanup(() => clearInterval(timer)); }); return ( - - DEEPSEEK - - - ... - + DEEPSEEK + {status()} - - - {(msg) => {msg()}} - - - - - - - - - ⚠ unavailable - - ); -}; - -const SidebarBalance = (props: { theme: Record }) => { - const palette = createMemo(() => getPalette(props.theme)); - return ; -}; +} -const tui: TuiPlugin = (api) => { +const tui: TuiPlugin = async (api) => { api.slots.register({ order: SIDEBAR_ORDER, slots: { - sidebar_content(ctx) { - return } />; + sidebar_content(_ctx, props) { + return ; }, }, }); - - return Promise.resolve(); }; -export default { +const plugin: TuiPluginModule & { id: string } = { id: "four-opencode-deepseek-meter", tui, }; + +export default plugin;