opencode-usage-report 0.3.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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * System-prompt capture sidecar. The server plugin writes the fully joined
3
+ * system prompt's size once per request; the TUI reads it back to split the
4
+ * residual "System & tools" row. Every operation is best-effort and total:
5
+ * failures degrade to `null` and must never break a request.
6
+ */
7
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import type { SystemCapture } from "./types";
10
+ import { pluginStateDir } from "../paths";
11
+
12
+ const CAPTURE_VERSION = 1;
13
+ const WRITE_THROTTLE_MS = 60_000;
14
+ const MAX_SESSION_ID_LENGTH = 200;
15
+
16
+ /** sessionID -> epoch ms of the last successful write, for throttling. */
17
+ const writeThrottle = new Map<string, number>();
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ function nonNegative(value: unknown): number | null {
24
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
25
+ }
26
+
27
+ /** <pluginStateDir>/context/<sanitized-sessionID>.json */
28
+ export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
29
+ const raw = typeof sessionID === "string" ? sessionID : "";
30
+ const safe = raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_SESSION_ID_LENGTH) || "_";
31
+ return join(pluginStateDir(env), "context", `${safe}.json`);
32
+ }
33
+
34
+ /**
35
+ * Reads and validates a capture. Returns null for a missing/unreadable file,
36
+ * invalid JSON, an unsupported version, a session mismatch, non-string
37
+ * provider/model, or non-finite/negative sizes and timestamps.
38
+ *
39
+ * Deliberately independent of the write-throttle map: the server plugin writes
40
+ * and the TUI plugin reads in separate processes, so a reader can never have a
41
+ * throttle entry of its own.
42
+ */
43
+ export async function readCapture(
44
+ sessionID: string,
45
+ env?: NodeJS.ProcessEnv,
46
+ ): Promise<SystemCapture | null> {
47
+ try {
48
+ if (typeof sessionID !== "string") return null;
49
+
50
+ const raw = await readFile(contextCapturePath(sessionID, env), "utf8");
51
+ const parsed: unknown = JSON.parse(raw);
52
+ if (!isRecord(parsed)) return null;
53
+ if (parsed.version !== CAPTURE_VERSION) return null;
54
+ if (parsed.sessionID !== sessionID) return null;
55
+ if (typeof parsed.providerID !== "string" || parsed.providerID === "") return null;
56
+ if (typeof parsed.modelID !== "string" || parsed.modelID === "") return null;
57
+
58
+ const systemChars = nonNegative(parsed.systemChars);
59
+ const systemTokens = nonNegative(parsed.systemTokens);
60
+ const capturedAt = nonNegative(parsed.capturedAt);
61
+ if (systemChars === null || systemTokens === null || capturedAt === null) return null;
62
+
63
+ return {
64
+ version: CAPTURE_VERSION,
65
+ sessionID,
66
+ providerID: parsed.providerID,
67
+ modelID: parsed.modelID,
68
+ systemChars,
69
+ systemTokens,
70
+ capturedAt,
71
+ };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Atomically persists a capture (temp file + rename), throttled to at most one
79
+ * write per session per 60 s unless the provider/model changed. Never throws.
80
+ */
81
+ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
82
+ try {
83
+ if (!isRecord(capture)) return;
84
+ const { sessionID, providerID, modelID } = capture;
85
+ if (typeof sessionID !== "string" || sessionID === "") return;
86
+ if (typeof providerID !== "string" || providerID === "") return;
87
+ if (typeof modelID !== "string" || modelID === "") return;
88
+ if (capture.version !== CAPTURE_VERSION) return;
89
+
90
+ const systemChars = nonNegative(capture.systemChars);
91
+ const systemTokens = nonNegative(capture.systemTokens);
92
+ const capturedAt = nonNegative(capture.capturedAt);
93
+ if (systemChars === null || systemTokens === null || capturedAt === null) return;
94
+
95
+ const previous = await readCapture(sessionID, env);
96
+ const modelChanged =
97
+ previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
98
+
99
+ const now = Date.now();
100
+ const last = writeThrottle.get(sessionID);
101
+ if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
102
+
103
+ const directory = join(pluginStateDir(env), "context");
104
+ await mkdir(directory, { recursive: true });
105
+ const file = contextCapturePath(sessionID, env);
106
+ const normalized: SystemCapture = {
107
+ version: CAPTURE_VERSION,
108
+ sessionID,
109
+ providerID,
110
+ modelID,
111
+ systemChars,
112
+ systemTokens,
113
+ capturedAt,
114
+ };
115
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
116
+ await writeFile(tmp, JSON.stringify(normalized), "utf8");
117
+ await rename(tmp, file);
118
+ writeThrottle.set(sessionID, now);
119
+ } catch {
120
+ // Capture writes are best-effort; a failure must never break a request.
121
+ }
122
+ }
123
+
124
+ /**
125
+ * True only when the capture matches the session/provider/model and was taken
126
+ * at or after `since`.
127
+ */
128
+ export function captureIsFresh(
129
+ capture: SystemCapture | null | undefined,
130
+ reference: { sessionID: string; providerID: string; modelID: string; since: number },
131
+ ): boolean {
132
+ if (!isRecord(capture)) return false;
133
+ const ref = isRecord(reference) ? reference : null;
134
+ if (ref === null) return false;
135
+ if (capture.sessionID !== ref.sessionID) return false;
136
+ if (capture.providerID !== ref.providerID) return false;
137
+ if (capture.modelID !== ref.modelID) return false;
138
+ const capturedAt = nonNegative(capture.capturedAt);
139
+ const since = nonNegative(ref.since);
140
+ if (capturedAt === null || since === null) return false;
141
+ return capturedAt >= since;
142
+ }
143
+
144
+ /** Clears the write-throttle map so tests can reset between cases. */
145
+ export function resetCaptureThrottle(): void {
146
+ writeThrottle.clear();
147
+ }
@@ -0,0 +1,51 @@
1
+ import type { Message, Model, Part } from "@opencode-ai/sdk/v2";
2
+
3
+ export type RowKey = "user" | "agent" | "reasoning" | "tools" | "system" | "free";
4
+
5
+ export interface ContextRow {
6
+ key: RowKey;
7
+ label: string;
8
+ tokens: number | null;
9
+ percent: number | null; // 0-100, null when the limit is unknown
10
+ exact: boolean;
11
+ }
12
+
13
+ export interface GridCell {
14
+ rowKey: RowKey | null; // null = free space
15
+ fill: number; // 0..1
16
+ }
17
+
18
+ export interface ContextBreakdown {
19
+ ready: boolean;
20
+ modelName: string | null;
21
+ total: number | null;
22
+ limit: number | null;
23
+ cost: number;
24
+ rows: ContextRow[];
25
+ grid: { cols: number; rows: number; cells: GridCell[] };
26
+ headroom: { usable: number | null; free: number | null; band: "ok" | "warning" | "error" };
27
+ systemDerived: boolean;
28
+ /** Measured system-prompt tokens when a valid capture was supplied, else null. */
29
+ systemCaptured: number | null;
30
+ prunedToolOutputs: number;
31
+ }
32
+
33
+ export interface SystemCapture {
34
+ version: 1;
35
+ sessionID: string;
36
+ providerID: string;
37
+ modelID: string;
38
+ systemChars: number;
39
+ systemTokens: number;
40
+ capturedAt: number;
41
+ }
42
+
43
+ export interface CollectInput {
44
+ messages: readonly Message[];
45
+ model: Model | null;
46
+ part?: ((messageID: string) => readonly Part[]) | null;
47
+ system?: SystemCapture | null;
48
+ sessionCost?: number;
49
+ cols?: number | null;
50
+ compaction?: { reserved?: number } | null;
51
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { renderJson, renderText } from "./render";
5
5
  import { checkWarnings } from "./warn";
6
6
  import { toNumber } from "./normalize";
7
7
  import { adapters } from "./providers/index";
8
+ import { estimateTokens } from "./context/estimate";
9
+ import { writeCapture } from "./context/system";
8
10
  import type { PluginOptions } from "./types";
9
11
 
10
12
  const WARN_THROTTLE_MS = 10 * 60 * 1000;
@@ -13,9 +15,6 @@ const TOOL_DESCRIPTION =
13
15
  "Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers " +
14
16
  `(${adapters.map((adapter) => adapter.id).join(", ")})`;
15
17
 
16
- const COMMAND_TEMPLATE =
17
- "Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
18
-
19
18
  /** Merges user plugin options over defaults, defensively ignoring malformed values. */
20
19
  export function coerceOptions(raw: Record<string, unknown> | undefined): PluginOptions {
21
20
  const opts: PluginOptions = { ...DEFAULT_OPTIONS };
@@ -91,12 +90,31 @@ const plugin: Plugin = async ({ client }, options) => {
91
90
  }),
92
91
  },
93
92
 
94
- async config(cfg) {
95
- cfg.command ??= {};
96
- cfg.command.usage ??= {
97
- description: "Show subscription usage/quota windows for configured providers",
98
- template: COMMAND_TEMPLATE,
99
- };
93
+ // `/usage` is owned by the TUI plugin so the slash command opens a local
94
+ // dialog instead of routing through the model. This hook's only job is to
95
+ // measure the assembled system prompt, which opencode never persists, so
96
+ // the TUI can split the "System & tools" residual (spec §9.1).
97
+ async "experimental.chat.system.transform"(input, output) {
98
+ try {
99
+ const sessionID = input?.sessionID;
100
+ if (typeof sessionID !== "string" || sessionID === "") return;
101
+ const model = input?.model;
102
+ if (model === undefined) return;
103
+ const system = Array.isArray(output?.system) ? output.system.join("\n") : "";
104
+ if (system === "") return;
105
+
106
+ await writeCapture({
107
+ version: 1,
108
+ sessionID,
109
+ providerID: model.providerID,
110
+ modelID: model.id,
111
+ systemChars: system.length,
112
+ systemTokens: estimateTokens(system),
113
+ capturedAt: Date.now(),
114
+ });
115
+ } catch {
116
+ // Best-effort: an experimental hook must never be able to break a request.
117
+ }
100
118
  },
101
119
 
102
120
  async event({ event }) {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pure line-building for the sidebar quota panel, extracted verbatim from
3
+ * `src/tui.tsx` so the formatting can be unit-tested and later reused by the
4
+ * `/usage` dialog. No JSX and no Solid imports (only the shared formatting
5
+ * helpers and types).
6
+ */
7
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
8
+ import type { ProviderReport, WindowKind } from "./types";
9
+ import { bar, countdown, percentText, toneFor, windowLabel } from "./tui-format";
10
+
11
+ type Theme = TuiPluginApi["theme"]["current"];
12
+ type ThemeColor = Theme["text"];
13
+
14
+ export interface Segment {
15
+ text: string;
16
+ fg: ThemeColor;
17
+ bold?: boolean;
18
+ }
19
+
20
+ export interface Line {
21
+ segments: Segment[];
22
+ }
23
+
24
+ export const NAMED_WIDTH = 14;
25
+ export const EXTRAS_MAX_LENGTH = 80;
26
+
27
+ /** Order windows for display; unknown kinds sort last. */
28
+ export const WINDOW_ORDER: Record<WindowKind, number> = {
29
+ "5h": 0,
30
+ weekly: 1,
31
+ monthly: 2,
32
+ daily: 3,
33
+ other: 4,
34
+ };
35
+
36
+ export function windowRank(kind: WindowKind): number {
37
+ return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
38
+ }
39
+
40
+ /** Collapses whitespace and truncates to a single-line label. */
41
+ export function clip(value: string, max = 60): string {
42
+ const single = value.replace(/\s+/g, " ").trim();
43
+ if (single.length <= max) return single;
44
+ return single.slice(0, Math.max(0, max - 1)) + "…";
45
+ }
46
+
47
+ /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
48
+ export function alignLabel(label: string): string {
49
+ const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
50
+ return text.padEnd(NAMED_WIDTH);
51
+ }
52
+
53
+ /** Flattens provider reports into plain, pre-colored text lines. */
54
+ export function buildLines(
55
+ theme: Theme,
56
+ reports: ProviderReport[],
57
+ busy: boolean,
58
+ error: string | null,
59
+ now: Date,
60
+ barWidth: number,
61
+ ): Line[] {
62
+ const lines: Line[] = [];
63
+
64
+ if (error !== null) {
65
+ lines.push({
66
+ segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
67
+ });
68
+ }
69
+
70
+ if (reports.length === 0 && busy) {
71
+ lines.push({
72
+ segments: [{ text: "loading…", fg: theme.textMuted }],
73
+ });
74
+ }
75
+
76
+ for (const report of reports) {
77
+ const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
78
+ const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
79
+ if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
80
+ if (report.source === "local-estimate") {
81
+ segments.push({ text: " (est)", fg: theme.textMuted });
82
+ }
83
+ lines.push({ segments });
84
+
85
+ if (report.source === "error") {
86
+ lines.push({
87
+ segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
88
+ });
89
+ continue;
90
+ }
91
+
92
+ const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
93
+ for (const w of windows) {
94
+ const tone = toneFor(w.usedPercent, w.status);
95
+ const row: Segment[] = [
96
+ { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
97
+ { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
98
+ { text: " ", fg: theme.text },
99
+ { text: percentText(w), fg: theme.text },
100
+ ];
101
+ const reset = countdown(w.resetsAt, now);
102
+ if (reset !== null) {
103
+ row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
104
+ }
105
+ lines.push({ segments: row });
106
+ }
107
+
108
+ for (const [key, value] of Object.entries(report.extras)) {
109
+ lines.push({
110
+ segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
111
+ });
112
+ }
113
+ }
114
+
115
+ return lines;
116
+ }
package/src/tui.tsx CHANGED
@@ -7,114 +7,19 @@ import type {
7
7
  } from "@opencode-ai/plugin/tui";
8
8
  import { For, createSignal } from "solid-js";
9
9
  import { DEFAULT_OPTIONS, collectReports } from "./report";
10
- import { bar, coerceTuiOptions, countdown, percentText, toneFor, windowLabel } from "./tui-format";
11
- import type { ProviderReport, WindowKind } from "./types";
12
-
13
- type Theme = TuiPluginApi["theme"]["current"];
14
- type ThemeColor = Theme["text"];
15
-
16
- interface Segment {
17
- text: string;
18
- fg: ThemeColor;
19
- bold?: boolean;
20
- }
21
-
22
- interface Line {
23
- segments: Segment[];
24
- }
25
-
26
- const NAMED_WIDTH = 14;
27
- const EXTRAS_MAX_LENGTH = 80;
28
-
29
- /** Order windows for display; unknown kinds sort last. */
30
- const WINDOW_ORDER: Record<WindowKind, number> = {
31
- "5h": 0,
32
- weekly: 1,
33
- monthly: 2,
34
- daily: 3,
35
- other: 4,
36
- };
37
-
38
- function windowRank(kind: WindowKind): number {
39
- return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
40
- }
41
-
42
- /** Collapses whitespace and truncates to a single-line label. */
43
- function clip(value: string, max = 60): string {
44
- const single = value.replace(/\s+/g, " ").trim();
45
- if (single.length <= max) return single;
46
- return single.slice(0, Math.max(0, max - 1)) + "…";
47
- }
48
-
49
- /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
50
- function alignLabel(label: string): string {
51
- const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
52
- return text.padEnd(NAMED_WIDTH);
53
- }
54
-
55
- /** Flattens the current signal state into plain, pre-colored text lines. */
56
- function buildLines(
57
- theme: Theme,
58
- reports: ProviderReport[],
59
- busy: boolean,
60
- error: string | null,
61
- now: Date,
62
- barWidth: number,
63
- ): Line[] {
64
- const lines: Line[] = [];
65
-
66
- if (error !== null) {
67
- lines.push({
68
- segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
69
- });
70
- }
71
-
72
- if (reports.length === 0 && busy) {
73
- lines.push({
74
- segments: [{ text: "loading…", fg: theme.textMuted }],
75
- });
76
- }
77
-
78
- for (const report of reports) {
79
- const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
80
- const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
81
- if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
82
- if (report.source === "local-estimate") {
83
- segments.push({ text: " (est)", fg: theme.textMuted });
84
- }
85
- lines.push({ segments });
86
-
87
- if (report.source === "error") {
88
- lines.push({
89
- segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
90
- });
91
- continue;
92
- }
93
-
94
- const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
95
- for (const w of windows) {
96
- const tone = toneFor(w.usedPercent, w.status);
97
- const row: Segment[] = [
98
- { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
99
- { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
100
- { text: " ", fg: theme.text },
101
- { text: percentText(w), fg: theme.text },
102
- ];
103
- const reset = countdown(w.resetsAt, now);
104
- if (reset !== null) {
105
- row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
106
- }
107
- lines.push({ segments: row });
108
- }
109
-
110
- for (const [key, value] of Object.entries(report.extras)) {
111
- lines.push({
112
- segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
113
- });
114
- }
115
- }
116
-
117
- return lines;
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;
118
23
  }
119
24
 
120
25
  export const tui: TuiPlugin = async (api, rawOptions) => {
@@ -124,11 +29,13 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
124
29
  const [inFlight, setInFlight] = createSignal(0);
125
30
  const [error, setError] = createSignal<string | null>(null);
126
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);
127
35
 
128
36
  const busy = (): boolean => inFlight() > 0;
129
37
 
130
38
  let disposed = false;
131
-
132
39
  const load = async (refresh = false): Promise<void> => {
133
40
  if (disposed) return;
134
41
  setInFlight((n) => n + 1);
@@ -153,11 +60,22 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
153
60
  }
154
61
  };
155
62
 
156
- // 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
+
157
74
  const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
158
75
  const timer = setInterval(() => {
159
76
  setTick(Date.now());
160
77
  void load();
78
+ void refreshCapture();
161
79
  }, intervalMs);
162
80
 
163
81
  api.lifecycle.onDispose(() => {
@@ -168,9 +86,35 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
168
86
  const offIdle = api.event.on("session.idle", () => {
169
87
  setTick(Date.now());
170
88
  void load();
89
+ void refreshCapture();
171
90
  });
172
91
  api.lifecycle.onDispose(offIdle);
173
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
+
174
118
  api.lifecycle.onDispose(
175
119
  api.keymap.registerLayer({
176
120
  commands: [
@@ -183,11 +127,47 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
183
127
  void load(true);
184
128
  },
185
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
+ },
186
139
  ],
187
140
  bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
188
141
  }),
189
142
  );
190
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
+ },
162
+ ],
163
+ bindings: [
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" },
167
+ ],
168
+ }),
169
+ );
170
+
191
171
  const slot: TuiSlotPlugin = {
192
172
  order: 150,
193
173
  slots: {