From 91f6631526accad9dfd819a762e03566ca122e13 Mon Sep 17 00:00:00 2001 From: Francisco Calle Moreno Date: Thu, 13 Aug 2026 00:32:05 +0200 Subject: [PATCH 1/5] fix: support keybind overrides via plugin options Refs #5 --- README.md | 34 ++++++++++++++++++----------- src/config.test.ts | 26 ++++++++++++++++++++++ src/config.ts | 15 +++++++++++++ src/tui.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++- src/tui.tsx | 48 +++++++++++++++++++++++++++++++---------- 5 files changed, 152 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index fc74aff..ad9957a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ array: | `refreshIntervalMinutes` | number, optional | `15` (min 1) | How often to re-fetch balances. | | `fields` | `"total"` \| `"split"`, optional | `"total"` | Show only totals, or also granted/topped-up breakdown. | | `providers` | string[] or string, optional | `[]` | List of provider ids to enable (e.g. `["deepseek"]`); empty = panel hidden. | +| `keybind` | string, optional | `shift+b` | Keybind that toggles the panel; `"none"` disables it. | +| `refreshKeybind` | string, optional | none | Keybind that refreshes balances; `"none"` disables it. | All options are optional; invalid values fall back to defaults. Threshold comparison is strict (`<`); omit `threshold` or set it to `null` to disable @@ -73,25 +75,31 @@ produce no messages. ## Toggle and commands -- `balance.toggle` — show/hide the panel. Default binding `B` - (leader, then shift+b). Plain `b` is opencode's built-in sidebar - toggle, so the plugin uses the shifted binding. To use plain `b` instead, - disable the built-in toggle and rebind in `tui.json`: +- `balance.toggle` — show/hide the panel. Default binding `shift+b` + (leader, then shift+b; the leader key defaults to `ctrl+x`). Plain + `b` is opencode's built-in sidebar toggle, so the panel toggle uses + shift+b and doesn't collide with it. +- `balance.refresh` — fetch balances now. No default binding; run it from the + command palette. + +Both commands appear in the command palette (`command_list`, default `ctrl+p`). + +Custom keybinds are set via PLUGIN OPTIONS, not `tui.json` keybinds. The host's +`keybinds` accepts only built-in keybind names and silently ignores plugin +commands, so overrides live in the plugin tuple: ```json { - "keybinds": { - "sidebar_toggle": "none", - "balance.toggle": "b" - } + "plugin": [ + [ + "opencode-provider-balance", + { "keybind": "ctrl+b", "refreshKeybind": "ctrl+r" } + ] + ] } ``` -- `balance.refresh` — fetch balances now. No default binding; run it from the - command palette. - -Both commands appear in the command palette (`command_list`, default `ctrl+p`). -The leader key defaults to `ctrl+x`. +Pass `"none"` to disable a binding (e.g. `"keybind": "none"`). ## Disable diff --git a/src/config.test.ts b/src/config.test.ts index 64caab0..e1ed219 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -22,6 +22,8 @@ const DEFAULTS = { refreshIntervalMs: 900_000, fields: "total" as const, providers: [] as string[], + keybind: null, + refreshKeybind: null, }; describe("parseOptions", () => { @@ -103,6 +105,30 @@ describe("parseOptions", () => { expect(parseOptions({ providers: [42, "deepseek"] }).providers).toEqual(["deepseek"]); expect(parseOptions({ providers: 42 }).providers).toEqual([]); }); + + test("defaults keybind and refreshKeybind to null", () => { + expect(parseOptions({}).keybind).toBeNull(); + expect(parseOptions({}).refreshKeybind).toBeNull(); + }); + + test("passes keybind strings through trimmed", () => { + expect(parseOptions({ keybind: "ctrl+b" }).keybind).toBe("ctrl+b"); + expect(parseOptions({ keybind: " shift+b " }).keybind).toBe( + "shift+b", + ); + }); + + test("treats empty or non-string keybinds as null", () => { + expect(parseOptions({ keybind: "" }).keybind).toBeNull(); + expect(parseOptions({ keybind: " " }).keybind).toBeNull(); + expect(parseOptions({ keybind: 42 }).keybind).toBeNull(); + expect(parseOptions({ refreshKeybind: 42 }).refreshKeybind).toBeNull(); + }); + + test("preserves the literal \"none\" sentinel for keybinds", () => { + expect(parseOptions({ keybind: "none" }).keybind).toBe("none"); + expect(parseOptions({ refreshKeybind: "none" }).refreshKeybind).toBe("none"); + }); }); describe("isLowBalance", () => { diff --git a/src/config.ts b/src/config.ts index 933b70d..8377ce2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,8 @@ export type BalancePluginOptions = { refreshIntervalMinutes?: number; fields?: "total" | "split"; providers?: string[] | string; + keybind?: string; + refreshKeybind?: string; }; export type NormalizedOptions = { @@ -14,10 +16,21 @@ export type NormalizedOptions = { refreshIntervalMs: number; fields: "total" | "split"; providers: string[]; + keybind: string | null; + refreshKeybind: string | null; }; const DEFAULT_REFRESH_INTERVAL_MINUTES = 15; +/** + * Parse a keybind option: undefined/non-string → null; string → trimmed; + * trimmed-empty → null. `"none"` passes through as the literal string (the + * caller treats it as "binding disabled"). + */ +function parseKeybindOption(raw: unknown): string | null { + return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null; +} + /** * Parse raw plugin options (the second tuple element in tui.json's plugin * entry) into a fully-normalized shape. Invalid values fall back to defaults; @@ -66,6 +79,8 @@ export function parseOptions(raw: Record | undefined): Normaliz refreshIntervalMs: Math.round(intervalMinutes * 60_000), fields, providers, + keybind: parseKeybindOption(raw?.keybind), + refreshKeybind: parseKeybindOption(raw?.refreshKeybind), }; } diff --git a/src/tui.test.ts b/src/tui.test.ts index 5c955d3..8a5216a 100644 --- a/src/tui.test.ts +++ b/src/tui.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { parseOptions } from "./config.js"; import { BalanceFetchError, BalanceKeyMissingError } from "./providers.js"; -import { classifyRefreshError } from "./tui.js"; +import { buildCommandBindings, classifyRefreshError } from "./tui.js"; describe("classifyRefreshError", () => { test("key-missing hides cached balances (with snapshot)", () => { @@ -38,3 +39,54 @@ describe("classifyRefreshError", () => { }); }); }); + +describe("buildCommandBindings", () => { + const DEFAULT_TOGGLE = { + key: "shift+b", + cmd: "balance.toggle", + desc: "Toggle balance panel", + mode: "base" as const, + }; + const REFRESH = { + key: "ctrl+r", + cmd: "balance.refresh", + desc: "Refresh balance", + mode: "base" as const, + }; + + test("defaults bind only the toggle to leader+shift+b", () => { + expect(buildCommandBindings(parseOptions({}))).toEqual([DEFAULT_TOGGLE]); + }); + + test("keybind override binds the toggle to the given key", () => { + expect(buildCommandBindings(parseOptions({ keybind: "ctrl+b" }))).toEqual([ + { ...DEFAULT_TOGGLE, key: "ctrl+b" }, + ]); + }); + + test('keybind "none" disables the toggle binding', () => { + expect(buildCommandBindings(parseOptions({ keybind: "none" }))).toEqual([]); + }); + + test("refreshKeybind adds a refresh binding alongside the default toggle", () => { + expect(buildCommandBindings(parseOptions({ refreshKeybind: "ctrl+r" }))).toEqual([ + DEFAULT_TOGGLE, + REFRESH, + ]); + }); + + test('refreshKeybind "none" keeps only the default toggle', () => { + expect(buildCommandBindings(parseOptions({ refreshKeybind: "none" }))).toEqual([ + DEFAULT_TOGGLE, + ]); + }); + + test("both overrides produce both bindings", () => { + expect( + buildCommandBindings(parseOptions({ keybind: "ctrl+b", refreshKeybind: "ctrl+r" })), + ).toEqual([ + { ...DEFAULT_TOGGLE, key: "ctrl+b" }, + REFRESH, + ]); + }); +}); diff --git a/src/tui.tsx b/src/tui.tsx index fed3075..13c4e3e 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,7 +1,7 @@ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; import { createSignal } from "solid-js"; import { readSnapshot, writeSnapshot } from "./cache.js"; -import { parseOptions } from "./config.js"; +import { parseOptions, type NormalizedOptions } from "./config.js"; import { BalancePanel } from "./panel.jsx"; import { BalanceFetchError, @@ -21,6 +21,41 @@ export type ProviderStatus = { error: RefreshErrorState; }; +export type CommandBinding = { + key: string; + cmd: string; + desc: string; + mode: "base"; +}; + +/** + * Build the keymap bindings for the plugin's commands from normalized options. + * null = plugin default; the literal "none" disables the binding. tui.json's + * host `keybinds` only accepts built-in keybind names and silently drops + * plugin commands, so overrides live in plugin options instead. + */ +export function buildCommandBindings(opts: NormalizedOptions): CommandBinding[] { + const bindings: CommandBinding[] = []; + const toggleKey = opts.keybind === null ? "shift+b" : opts.keybind; + if (toggleKey !== "none") { + bindings.push({ + key: toggleKey, + cmd: TOGGLE_COMMAND, + desc: "Toggle balance panel", + mode: "base", + }); + } + if (opts.refreshKeybind !== null && opts.refreshKeybind !== "none") { + bindings.push({ + key: opts.refreshKeybind, + cmd: REFRESH_COMMAND, + desc: "Refresh balance", + mode: "base", + }); + } + return bindings; +} + /** * Pure decision for how a refresh failure should surface in the panel. * key-missing hides all balance display, so nothing is flagged stale. @@ -128,16 +163,7 @@ const plugin: TuiPluginModule = { run: () => void refresh(), }, ], - bindings: api.tuiConfig.keybinds.has(TOGGLE_COMMAND) - ? [] - : [ - { - key: "B", - cmd: TOGGLE_COMMAND, - desc: "Toggle balance panel", - mode: "base", - }, - ], + bindings: buildCommandBindings(opts), }); // The host also auto-tracks keymap disposers; belt-and-suspenders. api.lifecycle.onDispose(disposeKeymap); From 9ecf570ae835ccd1ae566b2b0aed6eab0b94623a Mon Sep 17 00:00:00 2001 From: Francisco Calle Moreno Date: Thu, 13 Aug 2026 00:38:21 +0200 Subject: [PATCH 2/5] docs: recommend non-colliding refresh keybinding Refs #5 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ad9957a..ba438bf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ commands, so overrides live in the plugin tuple: "plugin": [ [ "opencode-provider-balance", - { "keybind": "ctrl+b", "refreshKeybind": "ctrl+r" } + { "keybind": "ctrl+b", "refreshKeybind": "f5" } ] ] } @@ -101,6 +101,12 @@ commands, so overrides live in the plugin tuple: Pass `"none"` to disable a binding (e.g. `"keybind": "none"`). +> Warning: avoid keys already used by opencode's built-in keybinds — built-in +> bindings take precedence over plugin bindings, so the plugin never fires. For +> example, `ctrl+r` is bound to rename session, `b` toggles the sidebar, +> and `r` redoes. Check the built-in list in opencode's keybinds docs +> before picking a binding (e.g. `f5` is unbound and works well for refresh). + ## Disable Disable the plugin without removing it. `plugin_enabled` is keyed by the plugin From 850943ddea1215d704f75afc1215769a2a06245a Mon Sep 17 00:00:00 2001 From: Francisco Calle Moreno Date: Thu, 13 Aug 2026 00:43:16 +0200 Subject: [PATCH 3/5] feat: log balance refresh outcomes via app log Refs #5 --- README.md | 7 +++++++ src/tui.tsx | 52 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ba438bf..be6e825 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,13 @@ Pass `"none"` to disable a binding (e.g. `"keybind": "none"`). > and `r` redoes. Check the built-in list in opencode's keybinds docs > before picking a binding (e.g. `f5` is unbound and works well for refresh). +## Logs + +Refresh outcomes are logged through opencode's app log under `service: +balance-panel` (info on success, warn/error on failures). Enable the debug +console with a built-in keybind in `tui.json`, e.g. +`{ "keybinds": { "app_console": "f9" } }`, then press `f9` to view. + ## Disable Disable the plugin without removing it. `plugin_enabled` is keyed by the plugin diff --git a/src/tui.tsx b/src/tui.tsx index 13c4e3e..9a76bf7 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -100,6 +100,14 @@ const plugin: TuiPluginModule = { } const [statuses, setStatuses] = createSignal>(initialStatuses); + const log = async (level: "debug" | "info" | "warn" | "error", message: string, extra: Record) => { + try { + await api.client.app.log({ service: "balance-panel", level, message, extra }); + } catch { + // logging must never break the refresh loop + } + }; + let refreshing = false; const refresh = async () => { if (refreshing) { @@ -116,22 +124,35 @@ const plugin: TuiPluginModule = { [provider.id]: { snapshot: fresh, stale: false, error: null }, })); writeSnapshot(api.kv, fresh); - } catch (err) { - setStatuses((prev) => { - const prevStatus = prev[provider.id]; - const { error, stale } = classifyRefreshError( - err, - prevStatus?.snapshot !== undefined, - ); - return { - ...prev, - [provider.id]: { - snapshot: prevStatus?.snapshot, - stale, - error, - }, - }; + await log("info", "balance refreshed", { + provider: provider.id, + balances: fresh.balances.map((b) => `${b.currency}:${b.totalBalance.toFixed(2)}`), + fetchedAt: fresh.fetchedAt, }); + } catch (err) { + const prevStatus = statuses()[provider.id]; + const { error, stale } = classifyRefreshError( + err, + prevStatus?.snapshot !== undefined, + ); + setStatuses((prev) => ({ + ...prev, + [provider.id]: { + snapshot: prevStatus?.snapshot, + stale, + error, + }, + })); + const errorDetail = err instanceof Error ? err.message : String(err); + if (error === "key-missing") { + await log("warn", "API key not configured", { provider: provider.id }); + } else if (error === "fetch-failed") { + await log("error", "balance unavailable", { provider: provider.id, error: errorDetail }); + } else if (stale) { + await log("warn", "balance refresh failed, showing cached", { provider: provider.id, error: errorDetail }); + } else { + await log("error", "unexpected refresh error", { provider: provider.id, error: errorDetail }); + } } }), ); @@ -141,6 +162,7 @@ const plugin: TuiPluginModule = { }; // Initial fetch on session start, then keep polling. + await log("info", "balance panel initialized", { providers: providers.map((p) => p.id) }); void refresh(); const timer = setInterval(() => void refresh(), opts.refreshIntervalMs); api.lifecycle.onDispose(() => clearInterval(timer)); From 051022f22c89f6b7d3c6680db38a0df3ca08f932 Mon Sep 17 00:00:00 2001 From: Francisco Calle Moreno Date: Thu, 13 Aug 2026 00:59:48 +0200 Subject: [PATCH 4/5] fix: route balance logs to the debug console overlay Refs #5 --- src/tui.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tui.tsx b/src/tui.tsx index 9a76bf7..160be76 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -101,8 +101,17 @@ const plugin: TuiPluginModule = { const [statuses, setStatuses] = createSignal>(initialStatuses); const log = async (level: "debug" | "info" | "warn" | "error", message: string, extra: Record) => { + // The OpenTUI console overlay (app_console) captures console.* calls, so + // that is the channel the user actually sees in the debug console. + // app.log is a best-effort server-side side channel and must never break + // the refresh loop. + if (level === "warn" || level === "error") { + console.error("[balance-panel]", level, message, extra); + } else { + console.log("[balance-panel]", level, message, extra); + } try { - await api.client.app.log({ service: "balance-panel", level, message, extra }); + await api.client?.app?.log?.({ service: "balance-panel", level, message, extra }); } catch { // logging must never break the refresh loop } From ba0706aba89a01756dd229636086e1df9628d795 Mon Sep 17 00:00:00 2001 From: Francisco Calle Moreno Date: Thu, 13 Aug 2026 00:59:51 +0200 Subject: [PATCH 5/5] feat: show last fetch time in balance panel Refs #5 --- src/panel.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/panel.tsx b/src/panel.tsx index 34529c4..bfaca2f 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -18,6 +18,13 @@ export type BalancePanelProps = { visible: boolean; }; +/** Formats an ISO timestamp as local 24h `HH:MM`; "" when unparseable. */ +function formatTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + /** * Provider-balance section for opencode's sidebar. Pure presentation: all * state arrives via props, nothing is fetched or stored here. @@ -82,6 +89,11 @@ export function BalancePanel(props: BalancePanelProps): JSX.Element { no balance data + + {formatTime(snapshot().fetchedAt) + ? ` (${formatTime(snapshot().fetchedAt)})` + : ""} + )}