opencode-usage-report 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types.js";
2
- import { adapters, getAdapter } from "./providers/index.js";
3
- import { redact, resolveCredential } from "./auth.js";
4
- import { ensureSessionId, readCache, writeCache } from "./cache.js";
5
- import type { CacheEntry } from "./cache.js";
6
- import { localEstimate } from "./fallback.js";
1
+ import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types";
2
+ import { adapters, getAdapter } from "./providers/index";
3
+ import { redact, resolveCredential } from "./auth";
4
+ import { ensureSessionId, readCache, writeCache } from "./cache";
5
+ import type { CacheEntry } from "./cache";
6
+ import { localEstimate } from "./fallback";
7
7
 
8
8
  export const DEFAULT_OPTIONS: PluginOptions = {
9
9
  thresholdPercent: 80,
@@ -102,7 +102,10 @@ async function collectOne(
102
102
  error: null,
103
103
  };
104
104
  } catch (err) {
105
- failure = redact(err instanceof Error ? err.message : String(err), cred.key);
105
+ failure = redact(
106
+ redact(err instanceof Error ? err.message : String(err), cred.key),
107
+ cred.accountId ?? null,
108
+ );
106
109
  }
107
110
 
108
111
  const cached = await readCache(id, options.cacheTtlSeconds, env);
@@ -136,7 +139,7 @@ async function collectOne(
136
139
  * `source: "error"` rows.
137
140
  */
138
141
  export async function collectReports(opts: CollectReportsOptions = {}): Promise<ProviderReport[]> {
139
- const options: PluginOptions = { ...DEFAULT_OPTIONS, ...(opts.options ?? {}) };
142
+ const options: PluginOptions = { ...DEFAULT_OPTIONS, ...opts.options };
140
143
  const env = opts.env ?? process.env;
141
144
  const refresh = opts.refresh === true;
142
145
 
package/src/tui-format.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Pure formatting helpers for the TUI sidebar usage panel. No JSX, no Solid
3
3
  * imports (the only module dependency is the shared `DEFAULT_OPTIONS`).
4
4
  */
5
- import { DEFAULT_OPTIONS } from "./report.js";
5
+ import { DEFAULT_OPTIONS } from "./report";
6
6
 
7
7
  export type ToneName = "success" | "warning" | "error" | "textMuted";
8
8
 
@@ -160,9 +160,7 @@ function toFiniteNumber(value: unknown): number | null {
160
160
  * `barWidth` is floored and clamped to 4..40; `refreshIntervalSeconds` is
161
161
  * rounded and clamped to 5..3600 so the timer can never hot-loop or overflow.
162
162
  */
163
- export function coerceTuiOptions(
164
- raw: Record<string, unknown> | undefined,
165
- ): ResolvedTuiOptions {
163
+ export function coerceTuiOptions(raw: Record<string, unknown> | undefined): ResolvedTuiOptions {
166
164
  const opts: ResolvedTuiOptions = {
167
165
  cacheTtlSeconds: DEFAULT_OPTIONS.cacheTtlSeconds,
168
166
  thresholdPercent: DEFAULT_OPTIONS.thresholdPercent,
package/src/tui.tsx CHANGED
@@ -6,129 +6,20 @@ import type {
6
6
  TuiSlotPlugin,
7
7
  } from "@opencode-ai/plugin/tui";
8
8
  import { For, createSignal } from "solid-js";
9
- import { DEFAULT_OPTIONS, collectReports } from "./report.js";
10
- import {
11
- bar,
12
- coerceTuiOptions,
13
- countdown,
14
- percentText,
15
- toneFor,
16
- windowLabel,
17
- } from "./tui-format.js";
18
- import type { ProviderReport, WindowKind } from "./types.js";
19
-
20
- type Theme = TuiPluginApi["theme"]["current"];
21
- type ThemeColor = Theme["text"];
22
-
23
- interface Segment {
24
- text: string;
25
- fg: ThemeColor;
26
- bold?: boolean;
27
- }
28
-
29
- interface Line {
30
- segments: Segment[];
31
- }
32
-
33
- const NAMED_WIDTH = 14;
34
- const EXTRAS_MAX_LENGTH = 80;
35
-
36
- /** Order windows for display; unknown kinds sort last. */
37
- const WINDOW_ORDER: Record<WindowKind, number> = {
38
- "5h": 0,
39
- weekly: 1,
40
- monthly: 2,
41
- daily: 3,
42
- other: 4,
43
- };
44
-
45
- function windowRank(kind: WindowKind): number {
46
- return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
47
- }
48
-
49
- /** Collapses whitespace and truncates to a single-line label. */
50
- function clip(value: string, max = 60): string {
51
- const single = value.replace(/\s+/g, " ").trim();
52
- if (single.length <= max) return single;
53
- return single.slice(0, Math.max(0, max - 1)) + "…";
54
- }
55
-
56
- /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
57
- function alignLabel(label: string): string {
58
- const text =
59
- label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
60
- return text.padEnd(NAMED_WIDTH);
61
- }
62
-
63
- /** Flattens the current signal state into plain, pre-colored text lines. */
64
- function buildLines(
65
- theme: Theme,
66
- reports: ProviderReport[],
67
- busy: boolean,
68
- error: string | null,
69
- now: Date,
70
- barWidth: number,
71
- ): Line[] {
72
- const lines: Line[] = [];
73
-
74
- if (error !== null) {
75
- lines.push({
76
- segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
77
- });
78
- }
79
-
80
- if (reports.length === 0 && busy) {
81
- lines.push({
82
- segments: [{ text: "loading…", fg: theme.textMuted }],
83
- });
84
- }
85
-
86
- for (const report of reports) {
87
- const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
88
- const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
89
- if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
90
- if (report.source === "local-estimate") {
91
- segments.push({ text: " (est)", fg: theme.textMuted });
92
- }
93
- lines.push({ segments });
94
-
95
- if (report.source === "error") {
96
- lines.push({
97
- segments: [
98
- { text: clip(report.error ?? "unknown error"), fg: theme.error },
99
- ],
100
- });
101
- continue;
102
- }
103
-
104
- const windows = [...report.windows].sort(
105
- (a, b) => windowRank(a.kind) - windowRank(b.kind),
106
- );
107
- for (const w of windows) {
108
- const tone = toneFor(w.usedPercent, w.status);
109
- const row: Segment[] = [
110
- { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
111
- { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
112
- { text: " ", fg: theme.text },
113
- { text: percentText(w), fg: theme.text },
114
- ];
115
- const reset = countdown(w.resetsAt, now);
116
- if (reset !== null) {
117
- row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
118
- }
119
- lines.push({ segments: row });
120
- }
121
-
122
- for (const [key, value] of Object.entries(report.extras)) {
123
- lines.push({
124
- segments: [
125
- { text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted },
126
- ],
127
- });
128
- }
129
- }
130
-
131
- return lines;
9
+ import { DEFAULT_OPTIONS, collectReports } from "./report";
10
+ import { coerceTuiOptions } from "./tui-format";
11
+ import { buildLines } from "./quota-lines";
12
+ import { UsageDialog } from "./context/dialog";
13
+ import { readCapture } from "./context/system";
14
+ import type { SystemCapture } from "./context/types";
15
+ import type { ProviderReport } from "./types";
16
+
17
+ /** Resolves the session the `/usage` dialog should describe, if any. */
18
+ function activeSessionID(api: TuiPluginApi): string | null {
19
+ const route = api.route.current;
20
+ if (route.name !== "session") return null;
21
+ const id: unknown = route.params?.sessionID;
22
+ return typeof id === "string" ? id : null;
132
23
  }
133
24
 
134
25
  export const tui: TuiPlugin = async (api, rawOptions) => {
@@ -138,11 +29,13 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
138
29
  const [inFlight, setInFlight] = createSignal(0);
139
30
  const [error, setError] = createSignal<string | null>(null);
140
31
  const [tick, setTick] = createSignal(Date.now());
32
+ const [capture, setCapture] = createSignal<SystemCapture | null>(null);
33
+ const [scope, setScope] = createSignal(0);
34
+ const [showJson, setShowJson] = createSignal(false);
141
35
 
142
36
  const busy = (): boolean => inFlight() > 0;
143
37
 
144
38
  let disposed = false;
145
-
146
39
  const load = async (refresh = false): Promise<void> => {
147
40
  if (disposed) return;
148
41
  setInFlight((n) => n + 1);
@@ -167,11 +60,22 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
167
60
  }
168
61
  };
169
62
 
170
- // Single timer: refreshes data and advances countdowns.
63
+ // Single timer: refreshes data, advances countdowns, reloads the capture.
64
+ const refreshCapture = async (): Promise<void> => {
65
+ if (disposed) return;
66
+ const sessionID = activeSessionID(api);
67
+ if (sessionID === null) {
68
+ setCapture(null);
69
+ return;
70
+ }
71
+ setCapture(await readCapture(sessionID));
72
+ };
73
+
171
74
  const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
172
75
  const timer = setInterval(() => {
173
76
  setTick(Date.now());
174
77
  void load();
78
+ void refreshCapture();
175
79
  }, intervalMs);
176
80
 
177
81
  api.lifecycle.onDispose(() => {
@@ -182,9 +86,35 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
182
86
  const offIdle = api.event.on("session.idle", () => {
183
87
  setTick(Date.now());
184
88
  void load();
89
+ void refreshCapture();
185
90
  });
186
91
  api.lifecycle.onDispose(offIdle);
187
92
 
93
+ const openDialog = (): void => {
94
+ const sessionID = activeSessionID(api);
95
+ void refreshCapture().finally(() => {
96
+ if (disposed) return;
97
+ setScope(0);
98
+ setShowJson(false);
99
+ api.ui.dialog.replace(() => (
100
+ <UsageDialog
101
+ api={api}
102
+ sessionID={sessionID}
103
+ reports={reports}
104
+ busy={busy}
105
+ error={error}
106
+ now={() => new Date(tick())}
107
+ system={capture}
108
+ scope={scope}
109
+ showJson={showJson}
110
+ barWidth={options.barWidth}
111
+ />
112
+ ));
113
+ // Must follow replace(): the stack resets the size to "medium".
114
+ api.ui.dialog.setSize("xlarge");
115
+ });
116
+ };
117
+
188
118
  api.lifecycle.onDispose(
189
119
  api.keymap.registerLayer({
190
120
  commands: [
@@ -197,9 +127,43 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
197
127
  void load(true);
198
128
  },
199
129
  },
130
+ {
131
+ name: "usage.open",
132
+ title: "Usage: open report",
133
+ description: "Context usage and provider quota windows",
134
+ category: "Plugin",
135
+ namespace: "palette",
136
+ slashName: "usage",
137
+ run: openDialog,
138
+ },
139
+ ],
140
+ bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
141
+ }),
142
+ );
143
+
144
+ // Scoped to the dialog's "modal" mode so these keys only fire while it is open.
145
+ api.lifecycle.onDispose(
146
+ api.keymap.registerLayer({
147
+ mode: "modal",
148
+ commands: [
149
+ { name: "usage.dialog.refresh", run: () => void load(true) },
150
+ {
151
+ name: "usage.dialog.scope",
152
+ run: () => {
153
+ setScope((n) => (n + 1) % Math.max(1, reports().length + 1));
154
+ },
155
+ },
156
+ {
157
+ name: "usage.dialog.json",
158
+ run: () => {
159
+ setShowJson((v) => !v);
160
+ },
161
+ },
200
162
  ],
201
163
  bindings: [
202
- { key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" },
164
+ { key: "r", cmd: "usage.dialog.refresh", desc: "Refresh usage report" },
165
+ { key: "tab", cmd: "usage.dialog.scope", desc: "Cycle provider scope" },
166
+ { key: "j", cmd: "usage.dialog.json", desc: "Toggle raw JSON" },
203
167
  ],
204
168
  }),
205
169
  );
@@ -215,7 +179,14 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
215
179
  <b>Usage</b>
216
180
  </text>
217
181
  <For
218
- each={buildLines(theme, reports(), busy(), error(), new Date(tick()), options.barWidth)}
182
+ each={buildLines(
183
+ theme,
184
+ reports(),
185
+ busy(),
186
+ error(),
187
+ new Date(tick()),
188
+ options.barWidth,
189
+ )}
219
190
  >
220
191
  {(line) => (
221
192
  <text>
package/src/types.ts CHANGED
@@ -28,6 +28,8 @@ export interface ProviderReport {
28
28
  export interface Credential {
29
29
  type: "api" | "oauth";
30
30
  key: string;
31
+ /** Provider account id, when the auth entry carries one (e.g. ChatGPT). */
32
+ accountId?: string;
31
33
  }
32
34
  export interface AdapterResult {
33
35
  windows: UsageWindow[];
@@ -50,7 +52,10 @@ export interface PluginOptions {
50
52
 
51
53
  export type AdapterErrorKind = "auth" | "no-plan" | "rate-limited" | "network" | "bad-response";
52
54
  export class AdapterError extends Error {
53
- constructor(public kind: AdapterErrorKind, message: string) {
55
+ constructor(
56
+ public kind: AdapterErrorKind,
57
+ message: string,
58
+ ) {
54
59
  super(message);
55
60
  this.name = "AdapterError";
56
61
  }
package/src/warn.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { ProviderReport, UsageWindow } from "./types.js";
4
- import { pluginStateDir } from "./paths.js";
5
- import { formatReset } from "./render.js";
3
+ import type { ProviderReport, UsageWindow } from "./types";
4
+ import { pluginStateDir } from "./paths";
5
+ import { formatReset } from "./render";
6
6
 
7
7
  export interface WarnHit {
8
8
  provider: string;
@@ -72,7 +72,8 @@ export async function checkWarnings(
72
72
  for (const w of report.windows) {
73
73
  const key = `${report.provider}/${w.kind}`;
74
74
  const crossed =
75
- (w.usedPercent !== null && w.usedPercent >= thresholdPercent) || HITS_WINDOW_STATUSES.has(w.status);
75
+ (w.usedPercent !== null && w.usedPercent >= thresholdPercent) ||
76
+ HITS_WINDOW_STATUSES.has(w.status);
76
77
 
77
78
  if (crossed) {
78
79
  const prev = state[key];