diff --git a/.changeset/expand-display-selection.md b/.changeset/expand-display-selection.md new file mode 100644 index 0000000..b9e29c5 --- /dev/null +++ b/.changeset/expand-display-selection.md @@ -0,0 +1,5 @@ +--- +"oc-usage-limits-plugin": minor +--- + +Add typed master display, sidebar window filtering, and per-provider footer window selection configuration. diff --git a/.changeset/provider-display-bars.md b/.changeset/provider-display-bars.md new file mode 100644 index 0000000..ab95f8b --- /dev/null +++ b/.changeset/provider-display-bars.md @@ -0,0 +1,5 @@ +--- +"oc-usage-limits-plugin": minor +--- + +Move display visibility and window selection to typed provider-level configuration fields. diff --git a/.changeset/separate-visibility-toggles.md b/.changeset/separate-visibility-toggles.md new file mode 100644 index 0000000..1534d80 --- /dev/null +++ b/.changeset/separate-visibility-toggles.md @@ -0,0 +1,5 @@ +--- +"oc-usage-limits-plugin": minor +--- + +Add separate `showSidebar` and `showFooter` configuration toggles for the v2 TUI displays. diff --git a/README.md b/README.md index 6ef776a..4a2cd03 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ Create `~/.config/opencode/usage-limits.jsonc`. The same file lives at [`example "codex": { "enabled": true, "label": "Codex", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", }, "zai": { "enabled": true, @@ -169,6 +173,10 @@ Disabled providers are hidden: } ``` +Top-level `enabled` is the plugin master switch, and `showErrors` controls error text globally. Each provider's `enabled` controls fetching. Provider `showSidebarBar` and `showFooterBar` independently control its sidebar and footer displays without stopping refreshes; both default to `true`. + +Each provider's `sidebarWindow` can be `all`, `rolling`, `daily`, `weekly`, `monthly`, `credits`, or `other`. Rolling includes legacy `5h` labels. Each provider accepts `footerWindow` with `auto` (the provider's normal selection), or one of the same window kinds. An unavailable requested footer window falls back to the provider's automatic selection and then its first available window. + ## Providers | Provider ID | Service | Env var | Auth header | Default base URL | diff --git a/__tests__/components.test.tsx b/__tests__/components.test.tsx index c201da4..68c090f 100644 --- a/__tests__/components.test.tsx +++ b/__tests__/components.test.tsx @@ -6,7 +6,12 @@ import { testRender } from "@opentui/solid"; import { Result } from "effect"; import { CompactStatusLine, UsageLimitsPanel } from "@/components.tsx"; -import type { ProviderState, ProviderUsage, UsageWindow } from "@/types.ts"; +import type { + ProviderDisplayConfig, + ProviderState, + ProviderUsage, + UsageWindow, +} from "@/types.ts"; import { parseUsagePercentage, percentageQuota } from "@/usage.ts"; const color = RGBA.fromValues(1, 2, 3, 255); @@ -42,7 +47,10 @@ const usage = (overrides: Partial = {}): ProviderUsage => ({ const renderPanelText = async ( states: ProviderState[], showErrors: boolean, - lastRefreshAt: Date | null = null + lastRefreshAt: Date | null = null, + providerDisplays: Readonly< + Partial> + > = {} ): Promise => { const setup = await testRender( () => ( @@ -51,6 +59,7 @@ const renderPanelText = async ( states={states} theme={theme} lastRefreshAt={lastRefreshAt} + providerDisplays={providerDisplays} /> ), { height: 12, width: 80 } @@ -86,6 +95,38 @@ describe("UsageLimitsPanel", () => { expect(text).toContain("[█████░░░░░░░]"); }); + test("filters windows by the provider sidebar window", async () => { + const text = await renderPanelText( + [ + { + data: usage({ + windows: [ + usageWindow(), + usageWindow({ kind: "weekly", label: "weekly" }), + ], + }), + id: "codex", + label: "Codex", + stale: false, + status: "ready", + }, + ], + true, + null, + { + codex: { + footerWindow: "auto", + showFooterBar: true, + showSidebarBar: true, + sidebarWindow: "weekly", + }, + } + ); + + expect(text).toContain("weekly"); + expect(text).not.toContain("5h"); + }); + test("renders previous windows and error text when errors are visible", async () => { const text = await renderPanelText( [ diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index d58bf9f..213c198 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -53,7 +53,11 @@ describe("configuration parsing", () => { authorizationScheme: "bearer", baseUrl: "https://example.com", enabled: true, + footerWindow: "weekly", label: "Work", + showFooterBar: false, + showSidebarBar: true, + sidebarWindow: "weekly", }, }, }); @@ -68,7 +72,11 @@ describe("configuration parsing", () => { authorizationScheme: "bearer", baseUrl: "https://example.com", enabled: true, + footerWindow: "weekly", label: "Work", + showFooterBar: false, + showSidebarBar: true, + sidebarWindow: "weekly", }); } }); diff --git a/__tests__/plugin.test.tsx b/__tests__/plugin.test.tsx index 825d5f0..791f8fa 100644 --- a/__tests__/plugin.test.tsx +++ b/__tests__/plugin.test.tsx @@ -243,6 +243,49 @@ describe("usage-limits TUI lifecycle", () => { ); }); + test("hides both displays without stopping provider refreshes", async () => { + const harness = createHarness( + config({ + providers: { + codex: { + enabled: true, + showFooterBar: false, + showSidebarBar: false, + }, + }, + }) + ); + const registered = await initialize(harness); + + expect(harness.fetches).toEqual(["codex"]); + expect(await renderSlot(registered, "sidebar.content")).not.toContain( + "Usage Limits" + ); + expect(await renderSlot(registered, "prompt.footer.status")).not.toContain( + "42%" + ); + }); + + test.each([ + [ + { providers: { codex: { enabled: true, showSidebarBar: false } } }, + "sidebar.content", + "Usage Limits", + ], + [ + { providers: { codex: { enabled: true, showFooterBar: false } } }, + "prompt.footer.status", + "42%", + ], + ])("hides the configured %s display", async (overrides, slot, text) => { + const harness = createHarness(config(overrides)); + const registered = await initialize(harness); + + expect( + await renderSlot(registered, slot as keyof CharacterizedSlots) + ).not.toContain(text); + }); + test("does not render footer usage for shell mode or missing sessions", async () => { const harness = createHarness(); const registered = await initialize(harness); diff --git a/__tests__/session.test.ts b/__tests__/session.test.ts index 2a7b5ce..d6ed4ed 100644 --- a/__tests__/session.test.ts +++ b/__tests__/session.test.ts @@ -130,6 +130,44 @@ describe("session helpers", () => { expect(Number(usage ? quotaUsedPercent(usage.quota) : null)).toBe(88); }); + test("selects a requested footer window and falls back to auto", () => { + const states: ProviderState[] = [ + { + data: { + capturedAt: new Date(), + id: "codex", + label: "Codex", + windows: [window("5h", 75), window("weekly", 88)], + }, + id: "codex", + label: "Codex", + stale: false, + status: "ready", + }, + ]; + + expect( + usageForProvider(states, "openai", { + codex: { + footerWindow: "weekly", + showFooterBar: true, + showSidebarBar: true, + sidebarWindow: "all", + }, + })?.label + ).toBe("weekly"); + expect( + usageForProvider(states, "openai", { + codex: { + footerWindow: "monthly", + showFooterBar: true, + showSidebarBar: true, + sidebarWindow: "all", + }, + })?.label + ).toBe("5h"); + }); + test("returns null for unknown providers or unavailable data", () => { expect(usageForProvider([], "anthropic")).toBeNull(); expect( diff --git a/examples/usage-limits.jsonc b/examples/usage-limits.jsonc index 94fd494..7ecd21f 100644 --- a/examples/usage-limits.jsonc +++ b/examples/usage-limits.jsonc @@ -8,6 +8,10 @@ "codex": { "enabled": true, "label": "Codex", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", }, "zai": { "enabled": true, diff --git a/src/components.tsx b/src/components.tsx index 96df1b6..ae6168a 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -10,7 +10,12 @@ import { windowResetText, windowResetTime, } from "@/format.ts"; -import type { ProviderState, UsageWindow } from "@/types.ts"; +import type { + ProviderDisplayConfig, + ProviderState, + SidebarWindow, + UsageWindow, +} from "@/types.ts"; import { quotaUsedPercent } from "@/usage.ts"; /** @@ -160,12 +165,47 @@ export const UsageLimitsPanel = (props: { showErrors: boolean; theme: UsageTheme; lastRefreshAt: Date | null; + providerDisplays: Readonly< + Partial> + >; }) => { const colors = resolveTheme(props.theme); + const displayConfigFor = (state: ProviderState): ProviderDisplayConfig => + props.providerDisplays[state.id] ?? { + footerWindow: "auto", + showFooterBar: true, + showSidebarBar: true, + sidebarWindow: "all", + }; + const filteredWindowsFor = ( + state: ProviderState, + windows: readonly UsageWindow[] + ): UsageWindow[] => { + const sidebarWindow: SidebarWindow = displayConfigFor(state).sidebarWindow; + return sidebarWindow === "all" + ? [...windows] + : windows.filter( + (window) => + window.kind === sidebarWindow || + (sidebarWindow === "rolling" && window.label === "5h") + ); + }; const visibleStates = createMemo(() => - props.states.filter((state) => - shouldRenderProviderState(state, props.showErrors) - ) + props.states.filter((state) => { + if (!shouldRenderProviderState(state, props.showErrors)) { + return false; + } + if (!displayConfigFor(state).showSidebarBar) { + return false; + } + if (state.status === "ready") { + return filteredWindowsFor(state, state.data.windows).length > 0; + } + if (state.status === "error" && state.previous) { + return filteredWindowsFor(state, state.previous.windows).length > 0; + } + return true; + }) ); return ( @@ -210,13 +250,13 @@ export const UsageLimitsPanel = (props: { {state.status === "ready" ? ( ) : null} {state.status === "error" && state.previous ? ( ) : null} {state.status === "error" && props.showErrors ? ( diff --git a/src/config-schema.ts b/src/config-schema.ts index 03f9068..d72c685 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -14,7 +14,33 @@ const secret = Schema.RedactedFromValue(Schema.String, { const commonProviderFields = { enabled: Schema.optionalKey(Schema.Boolean), + footerWindow: defaultKey( + Schema.Literals([ + "auto", + "rolling", + "daily", + "weekly", + "monthly", + "credits", + "other", + ]), + "auto" + ), label: Schema.optionalKey(Schema.String), + showFooterBar: defaultKey(Schema.Boolean, true), + showSidebarBar: defaultKey(Schema.Boolean, true), + sidebarWindow: defaultKey( + Schema.Literals([ + "all", + "rolling", + "daily", + "weekly", + "monthly", + "credits", + "other", + ]), + "all" + ), }; /** Schema for Codex provider configuration. */ diff --git a/src/coordinator.ts b/src/coordinator.ts index c74fdd8..d8a8606 100644 --- a/src/coordinator.ts +++ b/src/coordinator.ts @@ -8,6 +8,7 @@ import { defaultLabelFor } from "@/providers/index.ts"; import type { OpenCodeAuth, ProviderConfigMap, + ProviderDisplayConfig, ProviderID, ProviderState, ProviderUsage, @@ -16,6 +17,9 @@ import type { export interface CoordinatorSnapshot { readonly states: readonly ProviderState[]; + readonly providerDisplays: Readonly< + Partial> + >; readonly showErrors: boolean; readonly lastRefreshAt: Date | null; } @@ -54,6 +58,21 @@ const loadingState = ( status: "loading", }); +const providerDisplaysFor = ( + providers: readonly (readonly [ProviderID, ProviderConfigMap[ProviderID]])[] +): Partial> => + Object.fromEntries( + providers.map(([id, provider]) => [ + id, + { + footerWindow: provider.footerWindow ?? "auto", + showFooterBar: provider.showFooterBar ?? true, + showSidebarBar: provider.showSidebarBar ?? true, + sidebarWindow: provider.sidebarWindow ?? "all", + }, + ]) + ); + export const usageCoordinator = ( dependencies: UsageCoordinatorDependencies ): Effect.Effect => @@ -73,6 +92,7 @@ export const usageCoordinator = ( const providers = config.enabled ? getProviderConfigs(config) : []; yield* dependencies.publish({ lastRefreshAt: null, + providerDisplays: providerDisplaysFor(providers), showErrors: config.showErrors, states: providers.map(([id, provider]) => loadingState(id, provider)), }); @@ -123,6 +143,7 @@ export const usageCoordinator = ( const staleAfterMs = intervalMs * 2; yield* dependencies.publish({ lastRefreshAt: now, + providerDisplays: providerDisplaysFor(providers), showErrors: config.showErrors, states: terminalStates.map((state) => state.status === "ready" @@ -138,6 +159,7 @@ export const usageCoordinator = ( } else { yield* dependencies.publish({ lastRefreshAt: null, + providerDisplays: {}, showErrors: config.showErrors, states: [], }); diff --git a/src/plugin.tsx b/src/plugin.tsx index e8dc7fa..f9145b1 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -12,6 +12,7 @@ import { ProviderRuntimeLive } from "@/providers/runtime/index.ts"; import { currentProviderID, usageForProvider } from "@/session.ts"; import type { ProviderID, + ProviderDisplayConfig, OpenCodeAuth, ProviderConfigMap, ProviderState, @@ -61,12 +62,16 @@ export const createUsageLimitsPlugin = (dependencies: UsageLimitsTuiDependencies) => (context: Context): (() => void) => { const [states, setStates] = createSignal([]); + const [providerDisplays, setProviderDisplays] = createSignal< + Readonly>> + >({}); const [showErrors, setShowErrors] = createSignal(true); const [lastRefreshAt, setLastRefreshAt] = createSignal(null); const disposeSidebar = context.ui.slot({ append: "sidebar.content", render: () => ( ); }, @@ -99,6 +104,7 @@ export const createUsageLimitsPlugin = now: Effect.sync(dependencies.now), publish: (snapshot) => Effect.sync(() => { + setProviderDisplays(snapshot.providerDisplays); setShowErrors(snapshot.showErrors); setStates([...snapshot.states]); setLastRefreshAt(snapshot.lastRefreshAt); diff --git a/src/session.ts b/src/session.ts index 54e60d7..d3e2f9a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,11 +3,14 @@ import { PROVIDER_REGISTRY, } from "@/providers/index.ts"; import type { + ProviderDisplayConfig, ProviderID, + FooterWindow, ProviderState, ProviderUsage, UsageWindow, } from "@/types.ts"; +import type { UsageWindowKind } from "@/usage.ts"; import { isRecord } from "@/utils.ts"; /** @@ -104,7 +107,10 @@ const windowFromState = ( */ export const usageForProvider = ( states: readonly ProviderState[], - providerID: string | undefined + providerID: string | undefined, + providerDisplays: Readonly< + Partial> + > = {} ): UsageWindow | null => { const usageID = providerID ? (pluginProviderForOpenCode(providerID) as ProviderID | null) @@ -116,12 +122,27 @@ export const usageForProvider = ( if (!data) { return null; } + const displayConfig = providerDisplays[id]; + if (displayConfig?.showFooterBar === false) { + return null; + } + const requestedWindow: FooterWindow = displayConfig?.footerWindow ?? "auto"; const footerWindowKind = PROVIDER_REGISTRY[id]?.footerWindowKind; - const legacyLabel = - footerWindowKind === "rolling" ? "5h" : footerWindowKind; + const findForKind = (kind: UsageWindowKind | undefined) => { + if (!kind) { + return; + } + return ( + (kind === "rolling" + ? data.windows.find((window) => window.label === "5h") + : undefined) ?? data.windows.find((window) => window.kind === kind) + ); + }; + const requestedKind = + requestedWindow === "auto" ? footerWindowKind : requestedWindow; return ( - data.windows.find((window) => window.label === legacyLabel) ?? - data.windows.find((window) => window.kind === footerWindowKind) ?? + findForKind(requestedKind) ?? + (requestedWindow === "auto" ? null : findForKind(footerWindowKind)) ?? data.windows[0] ?? null ); diff --git a/src/types.ts b/src/types.ts index 10850dd..84f8d86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,20 @@ import type { Redacted } from "effect"; import type { ResetInstant, UsageQuota, UsageWindowKind } from "@/usage.ts"; +/** Requested sidebar window filter, or all available windows. */ +export type SidebarWindow = "all" | UsageWindowKind; + +/** Requested provider footer window, or the provider's automatic choice. */ +export type FooterWindow = "auto" | UsageWindowKind; + +/** Resolved display settings for one provider. */ +export interface ProviderDisplayConfig { + readonly showSidebarBar: boolean; + readonly showFooterBar: boolean; + readonly sidebarWindow: SidebarWindow; + readonly footerWindow: FooterWindow; +} + /** Provider adapters supported by the usage-limits plugin. */ export type ProviderID = | "codex" @@ -84,6 +98,11 @@ interface CommonProviderConfig { readonly enabled?: boolean; /** Optional provider display label override. */ readonly label?: string; + readonly showSidebarBar?: boolean; + readonly showFooterBar?: boolean; + readonly sidebarWindow?: SidebarWindow; + /** Preferred usage window for this provider's prompt footer. */ + readonly footerWindow?: FooterWindow; } /** Codex provider configuration. */ diff --git a/usage-limits.schema.json b/usage-limits.schema.json index 26d5586..68e2b9b 100644 --- a/usage-limits.schema.json +++ b/usage-limits.schema.json @@ -29,6 +29,32 @@ "additionalProperties": false, "properties": { "enabled": { "type": "boolean" }, + "showSidebarBar": { "type": "boolean", "default": true }, + "showFooterBar": { "type": "boolean", "default": true }, + "sidebarWindow": { + "enum": [ + "all", + "rolling", + "daily", + "weekly", + "monthly", + "credits", + "other" + ], + "default": "all" + }, + "footerWindow": { + "enum": [ + "auto", + "rolling", + "daily", + "weekly", + "monthly", + "credits", + "other" + ], + "default": "auto" + }, "label": { "type": "string" }, "authPath": { "type": "string",