diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 3cd934dd2..cc8be20d2 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -7,14 +7,14 @@ - **Source of truth:** `~/.local/share/opencode/opencode.db` - **Auth discovery:** `~/.local/share/opencode/auth.json` - **Provider ID:** `opencode-go` -- **Usage scope:** local observed assistant spend only +- **Usage scope:** local observed session spend only ## Detection The plugin enables when either condition is true: - `~/.local/share/opencode/auth.json` contains an `opencode-go` entry with a non-empty `key` -- local OpenCode history already contains `opencode-go` assistant messages with numeric `cost` +- local OpenCode history already contains `opencode-go` sessions with cost data If neither signal exists, the plugin stays hidden. @@ -24,16 +24,15 @@ OpenUsage reads the local OpenCode SQLite database directly: ```sql SELECT - CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, - CAST(json_extract(data, '$.cost') AS REAL) AS cost -FROM message -WHERE json_valid(data) - AND json_extract(data, '$.providerID') = 'opencode-go' - AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') + time_updated AS createdMs, + CAST(cost AS TEXT) AS cost +FROM session +WHERE json_valid(model) + AND json_extract(model, '$.providerID') = 'opencode-go' + AND cost > 0 ``` -Only assistant messages with numeric `cost` count. Missing remote or other-device usage is not estimated. +Only sessions with a positive cost count. `time_updated` is stored in milliseconds (same unit as `Date.now()`), confirmed from OpenCode's [`packages/core/src/session/sql.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/sql.ts) where `time_created` and `time_updated` use `Date.now()` as their default value. Missing remote or other-device usage is not estimated. ## Limits diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js index 6a1ce28d2..99bec4931 100644 --- a/plugins/opencode-go/plugin.js +++ b/plugins/opencode-go/plugin.js @@ -12,23 +12,21 @@ const HISTORY_EXISTS_SQL = ` SELECT 1 AS present - FROM message - WHERE json_valid(data) - AND json_extract(data, '$.providerID') = 'opencode-go' - AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') + FROM session + WHERE json_valid(model) + AND json_extract(model, '$.providerID') = 'opencode-go' + AND cost > 0 LIMIT 1 `; const HISTORY_ROWS_SQL = ` SELECT - CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, - CAST(json_extract(data, '$.cost') AS REAL) AS cost - FROM message - WHERE json_valid(data) - AND json_extract(data, '$.providerID') = 'opencode-go' - AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') + time_updated AS createdMs, + CAST(cost AS TEXT) AS cost + FROM session + WHERE json_valid(model) + AND json_extract(model, '$.providerID') = 'opencode-go' + AND cost > 0 `; function readNumber(value) { @@ -45,7 +43,7 @@ return 0; const percent = (used / limit) * 100; if (!Number.isFinite(percent)) return 0; - return Math.round(Math.max(0, Math.min(100, percent)) * 10) / 10; + return Math.floor(Math.max(0, Math.min(100, percent))); } function toIso(ms) { @@ -186,6 +184,7 @@ } function loadHistory(ctx) { + // time_updated is in milliseconds (same unit as Date.now()), confirmed from OpenCode source const result = queryRows(ctx, HISTORY_ROWS_SQL); if (!result.ok) return result; diff --git a/plugins/opencode-go/plugin.json b/plugins/opencode-go/plugin.json index 1b010135d..8664a5f4b 100644 --- a/plugins/opencode-go/plugin.json +++ b/plugins/opencode-go/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "opencode-go", "name": "OpenCode Go", - "version": "0.0.1", + "version": "0.0.2", "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#000000", diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js index d7cdf6f18..d601becc0 100644 --- a/plugins/opencode-go/plugin.test.js +++ b/plugins/opencode-go/plugin.test.js @@ -26,31 +26,19 @@ function setHistoryQuery(ctx, rows, options = {}) { if (String(sql).includes("SELECT 1 AS present")) { if (options.assertFilters !== false) { expect(String(sql)).toContain( - "json_extract(data, '$.providerID') = 'opencode-go'", - ); - expect(String(sql)).toContain( - "json_extract(data, '$.role') = 'assistant'", - ); - expect(String(sql)).toContain( - "json_type(data, '$.cost') IN ('integer', 'real')", + "json_extract(model, '$.providerID') = 'opencode-go'", ); + expect(String(sql)).toContain("cost > 0"); } return JSON.stringify(list.length > 0 ? [{ present: 1 }] : []); } if (options.assertFilters !== false) { expect(String(sql)).toContain( - "json_extract(data, '$.providerID') = 'opencode-go'", - ); - expect(String(sql)).toContain( - "json_extract(data, '$.role') = 'assistant'", - ); - expect(String(sql)).toContain( - "json_type(data, '$.cost') IN ('integer', 'real')", - ); - expect(String(sql)).toContain( - "COALESCE(json_extract(data, '$.time.created'), time_created)", + "json_extract(model, '$.providerID') = 'opencode-go'", ); + expect(String(sql)).toContain("time_updated"); + expect(String(sql)).toContain("cost > 0"); } return JSON.stringify(list); @@ -204,7 +192,7 @@ describe("opencode-go plugin", () => { const result = plugin.probe(ctx); const monthlyLine = result.lines.find((line) => line.label === "Monthly"); - expect(monthlyLine.used).toBe(4.5); + expect(monthlyLine.used).toBe(4); expect(monthlyLine.resetsAt).toBe("2026-03-25T07:53:16.000Z"); expect(monthlyLine.periodDurationMs).toBe(28 * 24 * 60 * 60 * 1000); }); @@ -245,6 +233,24 @@ describe("opencode-go plugin", () => { }); }); + it("parses fractional costs from CAST(cost AS TEXT) without integer truncation", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + // cost as a small decimal that would floor to 0 if truncated to integer + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-06T11:00:00.000Z"), cost: 0.7 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + // 0.7 / 12 * 100 = 5.83 → Math.floor = 5 + // If QuickJS truncated 0.7 to 0, this would be 0 + expect(result.lines[0].used).toBe(5); + }); + it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => { const ctx = makeCtx(); setAuth(ctx); diff --git a/src/App.test.tsx b/src/App.test.tsx index f0a6fe411..f7fb22b42 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -468,6 +468,18 @@ describe("App", () => { await screen.findByText("Now") }) + it("triggers refresh on visibilitychange when panel becomes visible", async () => { + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) + state.startBatchMock.mockClear() + + // Simulate panel becoming visible (document.hidden = false) + Object.defineProperty(document, "hidden", { configurable: true, value: false }) + fireEvent(document, new Event("visibilitychange")) + + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) + }) + it("updates tray icon on probe results when plugin has a primary progress", async () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { diff --git a/src/App.tsx b/src/App.tsx index 918e74dcd..6fd4a13fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -255,6 +255,17 @@ function App() { [pluginStates] ) + useEffect(() => { + const refreshOnShow = () => { + if (!document.hidden) { + startBatch() + } + } + + document.addEventListener("visibilitychange", refreshOnShow) + return () => document.removeEventListener("visibilitychange", refreshOnShow) + }, [startBatch]) + return ( void onBatchComplete: () => void @@ -38,7 +40,7 @@ export function useProbeEvents({ onResult, onBatchComplete }: UseProbeEventsOpti const setup = async () => { const resultUnlisten = await listen("probe:result", (event) => { - if (activeBatchIds.current.has(event.payload.batchId)) { + if (activeBatchIds.current.has(event.payload.batchId) || event.payload.batchId === BACKGROUND_BATCH_ID) { onResult(event.payload.output) } })