oc-go-usage-display 1.0.0 → 1.0.2
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/README.md +24 -1
- package/bin/lib.js +6 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +566 -0
- package/src/tui.tsx +538 -0
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
//
|
|
3
|
+
// OpenCode Go usage TUI plugin (dual-surface display).
|
|
4
|
+
//
|
|
5
|
+
// Renders subscription usage in two additive multi-render slots (sidebar order
|
|
6
|
+
// 50 sits above the context panel at 100 and below model-sidebar at 20;
|
|
7
|
+
// worktrunk renders elsewhere so multi-render stacking is unaffected):
|
|
8
|
+
// - `sidebar_content` -> titled block, e.g. `Go Usage` header plus one
|
|
9
|
+
// muted row per window (`5h 42%`, `7d 15%`, `30d 61%`). The header box
|
|
10
|
+
// carries no paddingLeft/gap so `Go Usage` aligns flush left like the
|
|
11
|
+
// `Context` header.
|
|
12
|
+
// - `session_prompt_right` -> compact single line next to the context status
|
|
13
|
+
// info (e.g. `Go 5h 42% | 7d 15%`), where the `80.6K (8%) · $0.09` readout
|
|
14
|
+
// lives. Additive multi-render only; `sidebar_footer` (single_winner,
|
|
15
|
+
// replaces name/version) is never used.
|
|
16
|
+
//
|
|
17
|
+
// Display surface is user-configurable (default both on; static selection
|
|
18
|
+
// still requires restart):
|
|
19
|
+
// 1. TUI plugin options in tui.json: `[..., {"sidebar": true, "statusline": true}]`
|
|
20
|
+
// 2. `OPENCODE_GO_SIDEBAR` / `OPENCODE_GO_STATUSLINE` env vars (0/1/false/true)
|
|
21
|
+
// 3. Legacy `display` option (`"sidebar"|"statusline"|"both"`) in tui.json,
|
|
22
|
+
// `OPENCODE_GO_DISPLAY` env var, or persisted `api.kv` key `display`
|
|
23
|
+
// (checked in that order when the new toggles are absent)
|
|
24
|
+
// Only the selected surface(s) register a slot. Both slots are additive
|
|
25
|
+
// multi-render (`sidebar_content` + `session_prompt_right`), so no
|
|
26
|
+
// `single_winner` slot is ever used; each slot returns null when its own
|
|
27
|
+
// collapse flag is set or when empty so it collapses instead of reserving
|
|
28
|
+
// space.
|
|
29
|
+
// (`session_prompt_right` is the primary statusline slot; if a future host
|
|
30
|
+
// drops it, the additive fallbacks would be `home_footer` / `home_bottom`.)
|
|
31
|
+
//
|
|
32
|
+
// Collapse is independent per surface and persisted in `api.kv`:
|
|
33
|
+
// - `collapsed_sidebar` toggled by `oc-go-usage-display.toggle-sidebar`
|
|
34
|
+
// (title `Go usage: toggle sidebar`), checked only by `sidebar_content`.
|
|
35
|
+
// - `collapsed_statusline` toggled by `oc-go-usage-display.toggle-statusline`
|
|
36
|
+
// (title `Go usage: toggle statusline`), checked only by
|
|
37
|
+
// `session_prompt_right`.
|
|
38
|
+
// The legacy single `collapsed` key is migrated once on startup (when true,
|
|
39
|
+
// both new keys are set true, then the legacy key is cleared) and ignored
|
|
40
|
+
// afterwards.
|
|
41
|
+
//
|
|
42
|
+
// Data: auth.json (dataShare ~/.local/share/opencode/auth.json, then legacy
|
|
43
|
+
// ~/.config/opencode/auth.json, `opencode-go` key else `opencode` key) as
|
|
44
|
+
// Bearer for GET https://opencode.ai/zen/go/v1/usage, refreshed on a 60s poll
|
|
45
|
+
// plus `session.updated` / `message.updated` events. Failures keep stale data
|
|
46
|
+
// and never break the host; errors go to api.client.app.log (never console).
|
|
47
|
+
// Secrets are never logged.
|
|
48
|
+
//
|
|
49
|
+
// Coexistence: the server plugin `src/index.ts` (`go_usage` tool only)
|
|
50
|
+
// stays as the headless/Desktop fallback. This module exports
|
|
51
|
+
// only `tui` (never `server`) under id `oc-go-usage-display`.
|
|
52
|
+
|
|
53
|
+
import type { PluginOptions } from "@opencode-ai/plugin";
|
|
54
|
+
import type {
|
|
55
|
+
TuiPlugin,
|
|
56
|
+
TuiPluginApi,
|
|
57
|
+
TuiPluginModule,
|
|
58
|
+
TuiSlotContext,
|
|
59
|
+
TuiTheme,
|
|
60
|
+
} from "@opencode-ai/plugin/tui";
|
|
61
|
+
import { For, Show, createEffect, createSignal } from "solid-js";
|
|
62
|
+
import * as fs from "node:fs";
|
|
63
|
+
import * as os from "node:os";
|
|
64
|
+
import * as path from "node:path";
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Constants
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
71
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
72
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
73
|
+
const SLOT_ORDER = 50;
|
|
74
|
+
const KV_DISPLAY_KEY = "display";
|
|
75
|
+
const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
|
|
76
|
+
const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
|
|
77
|
+
const KV_COLLAPSED_LEGACY_KEY = "collapsed";
|
|
78
|
+
const LOG_SERVICE = "oc-go-usage-display";
|
|
79
|
+
|
|
80
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
|
|
81
|
+
|
|
82
|
+
function dataShareAuthPath(): string {
|
|
83
|
+
const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
|
|
84
|
+
if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
|
|
85
|
+
return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function authJsonPaths(): string[] {
|
|
89
|
+
return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Trusted types (parsed at the boundary, trusted internally)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
type DisplayMode = "sidebar" | "statusline" | "both";
|
|
97
|
+
|
|
98
|
+
type SurfaceSelection = {
|
|
99
|
+
sidebar: boolean;
|
|
100
|
+
statusline: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type UsageWindow = {
|
|
104
|
+
percent: number;
|
|
105
|
+
resetInSec: number | null;
|
|
106
|
+
resetText: string | null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type UsageSnapshot = {
|
|
110
|
+
rolling: UsageWindow | null;
|
|
111
|
+
weekly: UsageWindow | null;
|
|
112
|
+
monthly: UsageWindow | null;
|
|
113
|
+
source: "api" | "mock" | "unavailable";
|
|
114
|
+
fetchedAt: number;
|
|
115
|
+
apiError?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type UsageRow = {
|
|
119
|
+
label: string;
|
|
120
|
+
value: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Small pure helpers
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
128
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
132
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toNonEmptyString(value: unknown): string | null {
|
|
137
|
+
if (typeof value !== "string") return null;
|
|
138
|
+
const trimmed = value.trim();
|
|
139
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isDisplayMode(value: unknown): value is DisplayMode {
|
|
143
|
+
return value === "sidebar" || value === "statusline" || value === "both";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
|
|
147
|
+
return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseBooleanFlag(value: unknown): boolean | null {
|
|
151
|
+
if (typeof value === "boolean") return value;
|
|
152
|
+
if (typeof value === "number") {
|
|
153
|
+
if (value === 1) return true;
|
|
154
|
+
if (value === 0) return false;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (typeof value !== "string") return null;
|
|
158
|
+
const normalized = value.trim().toLowerCase();
|
|
159
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
160
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatResetDuration(totalSec: number | null): string | null {
|
|
165
|
+
if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
|
|
166
|
+
const sec = Math.floor(totalSec);
|
|
167
|
+
const hours = Math.floor(sec / 3600);
|
|
168
|
+
const minutes = Math.floor((sec % 3600) / 60);
|
|
169
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
170
|
+
if (minutes > 0) return `${minutes}m`;
|
|
171
|
+
return `${sec}s`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
|
|
175
|
+
return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatCompactLine(snapshot: UsageSnapshot): string {
|
|
179
|
+
const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
|
|
180
|
+
const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
|
|
181
|
+
return `Go ${rolling} | ${weekly}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
|
|
185
|
+
const rows: UsageRow[] = [];
|
|
186
|
+
if (snapshot.rolling !== null) {
|
|
187
|
+
const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
|
|
188
|
+
rows.push({ label: "5h", value: `${snapshot.rolling.percent}%${reset ? ` · resets ${reset}` : ""}` });
|
|
189
|
+
}
|
|
190
|
+
if (snapshot.weekly !== null) rows.push({ label: "7d", value: `${snapshot.weekly.percent}%` });
|
|
191
|
+
if (snapshot.monthly !== null) rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
|
|
192
|
+
return rows;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Settings boundary (new toggles > legacy display; tui.json options > env >
|
|
197
|
+
// api.kv > default both on)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function resolveSurfaceSelection(options: PluginOptions | undefined, api: TuiPluginApi): SurfaceSelection {
|
|
201
|
+
if (options !== undefined) {
|
|
202
|
+
const sidebarOption = parseBooleanFlag(options.sidebar);
|
|
203
|
+
const statuslineOption = parseBooleanFlag(options.statusline);
|
|
204
|
+
if (sidebarOption !== null || statuslineOption !== null) {
|
|
205
|
+
return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
|
|
206
|
+
}
|
|
207
|
+
if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sidebarEnv = parseBooleanFlag(process.env.OPENCODE_GO_SIDEBAR);
|
|
211
|
+
const statuslineEnv = parseBooleanFlag(process.env.OPENCODE_GO_STATUSLINE);
|
|
212
|
+
if (sidebarEnv !== null || statuslineEnv !== null) {
|
|
213
|
+
return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const displayEnv = toNonEmptyString(process.env.OPENCODE_GO_DISPLAY);
|
|
217
|
+
if (displayEnv !== null && isDisplayMode(displayEnv)) {
|
|
218
|
+
return surfaceSelectionFromDisplayMode(displayEnv);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const stored = api.kv.get(KV_DISPLAY_KEY, "both");
|
|
223
|
+
if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
|
|
224
|
+
} catch {
|
|
225
|
+
// Persisted settings are best-effort; fall through to the default.
|
|
226
|
+
}
|
|
227
|
+
return { sidebar: true, statusline: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readCollapsedFlag(api: TuiPluginApi, key: string): boolean {
|
|
231
|
+
try {
|
|
232
|
+
return api.kv.get<boolean>(key, false) === true;
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function migrateLegacyCollapsedFlag(api: TuiPluginApi): void {
|
|
239
|
+
let legacyCollapsed = false;
|
|
240
|
+
try {
|
|
241
|
+
legacyCollapsed = api.kv.get<boolean>(KV_COLLAPSED_LEGACY_KEY, false) === true;
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!legacyCollapsed) return;
|
|
246
|
+
try {
|
|
247
|
+
api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
|
|
248
|
+
api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
|
|
249
|
+
api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
|
|
250
|
+
} catch {
|
|
251
|
+
// Collapse state is best-effort persistence only.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Data boundary (auth.json -> Bearer usage fetch; throws nothing)
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function readAuthJsonApiKey(): string | null {
|
|
260
|
+
for (const authPath of authJsonPaths()) {
|
|
261
|
+
let raw: string;
|
|
262
|
+
try {
|
|
263
|
+
raw = fs.readFileSync(authPath, "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let parsed: unknown;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(raw);
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!isRecord(parsed)) continue;
|
|
274
|
+
const goEntry = parsed["opencode-go"];
|
|
275
|
+
const fallbackEntry = parsed["opencode"];
|
|
276
|
+
const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
|
|
277
|
+
if (goKey) return goKey;
|
|
278
|
+
const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
|
|
279
|
+
if (fallbackKey) return fallbackKey;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function extractWindow(candidate: unknown): UsageWindow | null {
|
|
285
|
+
if (!isRecord(candidate)) return null;
|
|
286
|
+
const percent = toFiniteNumber(
|
|
287
|
+
candidate.percent ??
|
|
288
|
+
candidate.usagePercent ??
|
|
289
|
+
candidate.usedPercent ??
|
|
290
|
+
candidate.value ??
|
|
291
|
+
candidate.usage,
|
|
292
|
+
);
|
|
293
|
+
if (percent === null) return null;
|
|
294
|
+
return {
|
|
295
|
+
percent: Math.round(percent),
|
|
296
|
+
resetInSec: toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null),
|
|
297
|
+
resetText: toNonEmptyString(candidate.resetText ?? candidate.reset ?? null),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
|
|
302
|
+
if (!isRecord(payload)) return null;
|
|
303
|
+
const containers: unknown[] = [payload];
|
|
304
|
+
for (const key of ["usage", "data", "go"]) {
|
|
305
|
+
if (isRecord(payload[key])) containers.push(payload[key]);
|
|
306
|
+
}
|
|
307
|
+
for (const container of containers) {
|
|
308
|
+
if (!isRecord(container)) continue;
|
|
309
|
+
const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
|
|
310
|
+
const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
|
|
311
|
+
const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
|
|
312
|
+
if (rolling !== null || weekly !== null || monthly !== null) {
|
|
313
|
+
return { rolling, weekly, monthly, source: "api", fetchedAt: Date.now() };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetch(url, {
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
326
|
+
});
|
|
327
|
+
if (response.status === 401 || response.status === 403) return { __rejected: true };
|
|
328
|
+
if (!response.ok) return null;
|
|
329
|
+
try {
|
|
330
|
+
return (await response.json()) as unknown;
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
} finally {
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function mockSnapshot(): UsageSnapshot {
|
|
342
|
+
return {
|
|
343
|
+
rolling: { percent: 42, resetInSec: 7543, resetText: null },
|
|
344
|
+
weekly: { percent: 15, resetInSec: null, resetText: null },
|
|
345
|
+
monthly: { percent: 61, resetInSec: null, resetText: null },
|
|
346
|
+
source: "mock",
|
|
347
|
+
fetchedAt: Date.now(),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
|
|
352
|
+
if (process.env.OPENCODE_GO_MOCK === "1") return mockSnapshot();
|
|
353
|
+
|
|
354
|
+
const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY) ?? readAuthJsonApiKey();
|
|
355
|
+
if (apiKey === null) return null;
|
|
356
|
+
|
|
357
|
+
const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
|
|
358
|
+
if (payload === null) return null;
|
|
359
|
+
if (isRecord(payload) && payload.__rejected === true) return null;
|
|
360
|
+
return extractSnapshotFromApiPayload(payload);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function logUsageError(api: TuiPluginApi, message: string): Promise<void> {
|
|
364
|
+
try {
|
|
365
|
+
await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
|
|
366
|
+
} catch {
|
|
367
|
+
// Logging is best-effort; the usage display must never break the host.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// TUI plugin
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
const goUsageTui: TuiPlugin = async (api, options) => {
|
|
376
|
+
const surfaces = resolveSurfaceSelection(options, api);
|
|
377
|
+
migrateLegacyCollapsedFlag(api);
|
|
378
|
+
|
|
379
|
+
const [usageSnapshot, setUsageSnapshot] = createSignal<UsageSnapshot | null>(null);
|
|
380
|
+
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal<boolean>(
|
|
381
|
+
readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
|
|
382
|
+
);
|
|
383
|
+
const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
|
|
384
|
+
readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
let cachedAt = 0;
|
|
388
|
+
let refreshInFlight = false;
|
|
389
|
+
|
|
390
|
+
async function refreshUsage(): Promise<void> {
|
|
391
|
+
if (refreshInFlight) return;
|
|
392
|
+
if (Date.now() - cachedAt < POLL_INTERVAL_MS && usageSnapshot() !== null) return;
|
|
393
|
+
refreshInFlight = true;
|
|
394
|
+
try {
|
|
395
|
+
const snapshot = await loadUsageSnapshot();
|
|
396
|
+
if (snapshot === null) {
|
|
397
|
+
if (usageSnapshot() === null) {
|
|
398
|
+
await logUsageError(api, "Go usage unavailable (not configured or request failed)");
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
cachedAt = Date.now();
|
|
403
|
+
setUsageSnapshot(snapshot);
|
|
404
|
+
} catch {
|
|
405
|
+
// Keep stale data; the panel simply shows the last known snapshot.
|
|
406
|
+
} finally {
|
|
407
|
+
refreshInFlight = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function toggleSidebarCollapsed(): void {
|
|
412
|
+
const next = !isSidebarCollapsed();
|
|
413
|
+
setIsSidebarCollapsed(next);
|
|
414
|
+
try {
|
|
415
|
+
api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
|
|
416
|
+
} catch {
|
|
417
|
+
// Collapse state is best-effort persistence only.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function toggleStatuslineCollapsed(): void {
|
|
422
|
+
const next = !isStatuslineCollapsed();
|
|
423
|
+
setIsStatuslineCollapsed(next);
|
|
424
|
+
try {
|
|
425
|
+
api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
|
|
426
|
+
} catch {
|
|
427
|
+
// Collapse state is best-effort persistence only.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function GoSidebarPanel(props: { theme: TuiTheme }) {
|
|
432
|
+
createEffect(() => {
|
|
433
|
+
const snapshot = usageSnapshot();
|
|
434
|
+
if (snapshot !== null && snapshot.source === "unavailable") {
|
|
435
|
+
void logUsageError(api, "Go usage snapshot unavailable");
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
return (
|
|
439
|
+
<box flexDirection="column">
|
|
440
|
+
<text fg={props.theme.current.text}>
|
|
441
|
+
<b>Go Usage</b>
|
|
442
|
+
</text>
|
|
443
|
+
<Show
|
|
444
|
+
when={usageSnapshot()}
|
|
445
|
+
fallback={
|
|
446
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
447
|
+
Go loading…
|
|
448
|
+
</text>
|
|
449
|
+
}
|
|
450
|
+
>
|
|
451
|
+
{(snapshot) => (
|
|
452
|
+
<For each={buildUsageRows(snapshot())}>
|
|
453
|
+
{(row) => (
|
|
454
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
455
|
+
{row.label} {row.value}
|
|
456
|
+
</text>
|
|
457
|
+
)}
|
|
458
|
+
</For>
|
|
459
|
+
)}
|
|
460
|
+
</Show>
|
|
461
|
+
</box>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function GoStatusline() {
|
|
466
|
+
return (
|
|
467
|
+
<Show when={usageSnapshot()} fallback={null}>
|
|
468
|
+
{(snapshot) => {
|
|
469
|
+
if (isSnapshotEmpty(snapshot())) return null;
|
|
470
|
+
return <text>{formatCompactLine(snapshot())}</text>;
|
|
471
|
+
}}
|
|
472
|
+
</Show>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (surfaces.sidebar) {
|
|
477
|
+
api.slots.register({
|
|
478
|
+
order: SLOT_ORDER,
|
|
479
|
+
slots: {
|
|
480
|
+
sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
|
|
481
|
+
if (props.session_id.length === 0) return null;
|
|
482
|
+
if (api.route.current.name !== "session") return null;
|
|
483
|
+
if (isSidebarCollapsed()) return null;
|
|
484
|
+
return <GoSidebarPanel theme={ctx.theme} />;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (surfaces.statusline) {
|
|
491
|
+
api.slots.register({
|
|
492
|
+
order: SLOT_ORDER,
|
|
493
|
+
slots: {
|
|
494
|
+
session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
|
|
495
|
+
if (props.session_id.length === 0) return null;
|
|
496
|
+
if (isStatuslineCollapsed()) return null;
|
|
497
|
+
return <GoStatusline />;
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const unregisterToggleCommand = api.command.register(() => [
|
|
504
|
+
{
|
|
505
|
+
title: "Go usage: toggle sidebar",
|
|
506
|
+
value: "oc-go-usage-display.toggle-sidebar",
|
|
507
|
+
category: "Go",
|
|
508
|
+
onSelect: () => toggleSidebarCollapsed(),
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
title: "Go usage: toggle statusline",
|
|
512
|
+
value: "oc-go-usage-display.toggle-statusline",
|
|
513
|
+
category: "Go",
|
|
514
|
+
onSelect: () => toggleStatuslineCollapsed(),
|
|
515
|
+
},
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const unsubscribeSession = api.event.on("session.updated", () => {
|
|
519
|
+
void refreshUsage();
|
|
520
|
+
});
|
|
521
|
+
const unsubscribeMessage = api.event.on("message.updated", () => {
|
|
522
|
+
void refreshUsage();
|
|
523
|
+
});
|
|
524
|
+
const pollTimer = setInterval(() => {
|
|
525
|
+
void refreshUsage();
|
|
526
|
+
}, POLL_INTERVAL_MS);
|
|
527
|
+
|
|
528
|
+
api.lifecycle.onDispose(() => {
|
|
529
|
+
clearInterval(pollTimer);
|
|
530
|
+
unsubscribeSession();
|
|
531
|
+
unsubscribeMessage();
|
|
532
|
+
unregisterToggleCommand();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
await refreshUsage();
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;
|