oc-go-usage-display 1.1.0 → 2.0.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/README.md +245 -93
- package/bin/lib.js +395 -37
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +22 -17
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +31 -52
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +116 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +568 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -159
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
- package/dist/plugins/oc-go-usage-display.ts +678 -0
- package/dist/plugins/oc-go-usage-display.tsx +1018 -0
- package/dist/shared.d.ts +75 -7
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +448 -25
- package/dist/shared.js.map +1 -1
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +372 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts +0 -17
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +229 -282
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +46 -14
- package/src/helpers.ts +733 -0
- package/src/index.ts +316 -175
- package/src/shared.ts +545 -28
- package/src/tui-shared.tsx +705 -0
- package/src/tui.kilo.tsx +816 -0
- package/src/tui.tsx +352 -366
package/src/tui.tsx
CHANGED
|
@@ -2,42 +2,44 @@
|
|
|
2
2
|
//
|
|
3
3
|
// OpenCode Go usage TUI plugin (dual-surface display).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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% | 30d 61%`), 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.
|
|
5
|
+
// Everything shared with the Kilo entry -- the refresh policy, the settings
|
|
6
|
+
// ladder, the plan meters, the row grammar, the failure boundaries -- lives in
|
|
7
|
+
// `./tui-shared.jsx` and is inlined into this bundle. What is left here is what
|
|
8
|
+
// is genuinely opencode-specific:
|
|
16
9
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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`.)
|
|
10
|
+
// - `SLOT_ORDER` (50) sits in the free band above every host panel, so the
|
|
11
|
+
// block renders at the top of the sidebar. OpenCode's own `sidebar_content`
|
|
12
|
+
// ladder starts at 100 (`sidebar-context`) and continues 200 mcp / 300 lsp /
|
|
13
|
+
// 400 todo / 500 files, and it registers no `session_prompt_right` panel at
|
|
14
|
+
// all, so the statusline has nothing to stack against. Worktrunk renders
|
|
15
|
+
// elsewhere so multi-render stacking is unaffected.
|
|
16
|
+
// - both surfaces are additive multi-render (`sidebar_content` +
|
|
17
|
+
// `session_prompt_right`); no `single_winner` slot is ever used.
|
|
18
|
+
// - this host has no usage panel of its own to retire, so there is no
|
|
19
|
+
// sidebar_mode axis: the block is one thing, at one order.
|
|
31
20
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
21
|
+
// WHAT THE BLOCK RENDERS
|
|
22
|
+
// ---------------------
|
|
23
|
+
// The sidebar is ~30 cells wide here (Kilo's is ~40), which decides the whole
|
|
24
|
+
// layout: the plan is the meters themselves rather than label/value lines, and
|
|
25
|
+
// the per-model section is a foldable block that starts collapsed.
|
|
26
|
+
//
|
|
27
|
+
// Go Usage
|
|
28
|
+
// 5h resets in 2h5m the next plan window, labelled, one line
|
|
29
|
+
// 5h [#######.....] 42% the three windows, 10-cell meters
|
|
30
|
+
// 7d [#...........] 15%
|
|
31
|
+
// 30d [#########..] 61%
|
|
32
|
+
// > Top Go models (7) one line: the mix, cheapest thing to keep
|
|
33
|
+
// mimo 59% · qwen 25% ...
|
|
34
|
+
// (expanded: one row per model with a 6-cell
|
|
35
|
+
// weight bar, then what the rows cover and the
|
|
36
|
+
// session's Go totals)
|
|
37
|
+
//
|
|
38
|
+
// The weight of a model is its share of the Go tokens spent in THIS session --
|
|
39
|
+
// measured the way the host's own Context panel measures the number next to it,
|
|
40
|
+
// i.e. over the rendered session's assistant messages, not the session tree.
|
|
41
|
+
// See `aggregateModelUsageFromMessages` for why opencode needs no endpoint for
|
|
42
|
+
// this and what "the same shape as Kilo's" buys.
|
|
41
43
|
//
|
|
42
44
|
// Data: auth.json (dataShare ~/.local/share/opencode/auth.json, then legacy
|
|
43
45
|
// ~/.config/opencode/auth.json, `opencode-go` key else `opencode` key) as
|
|
@@ -58,315 +60,216 @@ import type {
|
|
|
58
60
|
TuiSlotContext,
|
|
59
61
|
TuiTheme,
|
|
60
62
|
} from "@opencode-ai/plugin/tui";
|
|
61
|
-
import { For, Show,
|
|
63
|
+
import { For, Show, createMemo, createSignal } from "solid-js";
|
|
64
|
+
import {
|
|
65
|
+
buildGoModelFooters,
|
|
66
|
+
buildModelMixSummary,
|
|
67
|
+
relevantReset,
|
|
68
|
+
formatPercentCell,
|
|
69
|
+
meterSeverityForPercent,
|
|
70
|
+
modelDisplayName,
|
|
71
|
+
shortModelName,
|
|
72
|
+
weightGoModels,
|
|
73
|
+
} from "./helpers.js";
|
|
74
|
+
import type { GoModelWeight } from "./helpers.js";
|
|
62
75
|
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
toNonEmptyString,
|
|
69
|
-
unavailableSnapshot,
|
|
76
|
+
aggregateModelUsageFromMessages,
|
|
77
|
+
errorMessage,
|
|
78
|
+
MODELS_EMPTY_LABEL,
|
|
79
|
+
OPENCODE_MODEL_NAME_MAX_CHARS,
|
|
80
|
+
TOP_GO_MODELS_LABEL,
|
|
70
81
|
} from "./shared.js";
|
|
71
|
-
import type {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
import type { SessionModelUsage } from "./shared.js";
|
|
83
|
+
import {
|
|
84
|
+
CollapsibleSection,
|
|
85
|
+
GoStatusline,
|
|
86
|
+
GoUsageBlock,
|
|
87
|
+
MeterBar,
|
|
88
|
+
createCollapseState,
|
|
89
|
+
createUsageStore,
|
|
90
|
+
isGoUsageProvider,
|
|
91
|
+
logUsageError,
|
|
92
|
+
makeProviderResolver,
|
|
93
|
+
readModelDisplayName,
|
|
94
|
+
reactiveChild,
|
|
95
|
+
resolveSurfaceSelection,
|
|
96
|
+
} from "./tui-shared.js";
|
|
97
|
+
import { EVENT_TTL_MS, POLL_INTERVAL_MS } from "./tui-shared.js";
|
|
98
|
+
import type { DisplayOptions } from "./tui-shared.js";
|
|
99
|
+
|
|
100
|
+
// This entry is the opencode build, so it resolves `OPENCODE_OC_GO_*` and never
|
|
101
|
+
// reads a Kilo-prefixed name.
|
|
102
|
+
const HOST = "opencode" as const;
|
|
83
103
|
const SLOT_ORDER = 50;
|
|
84
|
-
const KV_DISPLAY_KEY = "display";
|
|
85
|
-
const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
|
|
86
|
-
const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
|
|
87
|
-
const KV_COLLAPSED_LEGACY_KEY = "collapsed";
|
|
88
|
-
const LOG_SERVICE = "oc-go-usage-display";
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// Trusted types (parsed at the boundary, trusted internally; usage shapes
|
|
92
|
-
// live in `./shared.js` so server and TUI parse identically)
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
type DisplayMode = "sidebar" | "statusline" | "both";
|
|
96
|
-
|
|
97
|
-
type SurfaceSelection = {
|
|
98
|
-
sidebar: boolean;
|
|
99
|
-
statusline: boolean;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
type UsageRow = {
|
|
103
|
-
label: string;
|
|
104
|
-
value: string;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
// Small pure helpers
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
function isDisplayMode(value: unknown): value is DisplayMode {
|
|
112
|
-
return value === "sidebar" || value === "statusline" || value === "both";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
|
|
116
|
-
return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function parseBooleanFlag(value: unknown): boolean | null {
|
|
120
|
-
if (typeof value === "boolean") return value;
|
|
121
|
-
if (typeof value === "number") {
|
|
122
|
-
if (value === 1) return true;
|
|
123
|
-
if (value === 0) return false;
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
if (typeof value !== "string") return null;
|
|
127
|
-
const normalized = value.trim().toLowerCase();
|
|
128
|
-
if (normalized === "1" || normalized === "true") return true;
|
|
129
|
-
if (normalized === "0" || normalized === "false") return false;
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
|
|
134
|
-
return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function formatCompactLine(snapshot: UsageSnapshot): string {
|
|
138
|
-
const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
|
|
139
|
-
const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
|
|
140
|
-
const monthly = snapshot.monthly === null ? "30d n/a" : `30d ${snapshot.monthly.percent}%`;
|
|
141
|
-
return `Go ${rolling} | ${weekly} | ${monthly}`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
|
|
145
|
-
const rows: UsageRow[] = [];
|
|
146
|
-
if (snapshot.rolling !== null) {
|
|
147
|
-
const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
|
|
148
|
-
rows.push({ label: "5h", value: `${snapshot.rolling.percent}%${reset ? ` · resets ${reset}` : ""}` });
|
|
149
|
-
}
|
|
150
|
-
if (snapshot.weekly !== null) rows.push({ label: "7d", value: `${snapshot.weekly.percent}%` });
|
|
151
|
-
if (snapshot.monthly !== null) rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
|
|
152
|
-
return rows;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Settings boundary (new toggles > legacy display; tui.json options > env >
|
|
157
|
-
// api.kv > default both on)
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
|
|
160
|
-
function resolveSurfaceSelection(options: PluginOptions | undefined, api: TuiPluginApi): SurfaceSelection {
|
|
161
|
-
if (options !== undefined) {
|
|
162
|
-
const sidebarOption = parseBooleanFlag(options.sidebar);
|
|
163
|
-
const statuslineOption = parseBooleanFlag(options.statusline);
|
|
164
|
-
if (sidebarOption !== null || statuslineOption !== null) {
|
|
165
|
-
return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
|
|
166
|
-
}
|
|
167
|
-
if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const sidebarEnv = parseBooleanFlag(process.env.OPENCODE_GO_SIDEBAR);
|
|
171
|
-
const statuslineEnv = parseBooleanFlag(process.env.OPENCODE_GO_STATUSLINE);
|
|
172
|
-
if (sidebarEnv !== null || statuslineEnv !== null) {
|
|
173
|
-
return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const displayEnv = toNonEmptyString(process.env.OPENCODE_GO_DISPLAY);
|
|
177
|
-
if (displayEnv !== null && isDisplayMode(displayEnv)) {
|
|
178
|
-
return surfaceSelectionFromDisplayMode(displayEnv);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
const stored = api.kv.get(KV_DISPLAY_KEY, "both");
|
|
183
|
-
if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
|
|
184
|
-
} catch {
|
|
185
|
-
// Persisted settings are best-effort; fall through to the default.
|
|
186
|
-
}
|
|
187
|
-
return { sidebar: true, statusline: true };
|
|
188
|
-
}
|
|
189
104
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
105
|
+
// The factory body lives here so the exported `goUsageTui` can wrap the whole
|
|
106
|
+
// initialization in a single fail-safe boundary. A throwing factory would
|
|
107
|
+
// destabilize the host's plugin load, so it must never reject.
|
|
108
|
+
async function initializeTui(api: TuiPluginApi, options: PluginOptions | undefined): Promise<void> {
|
|
109
|
+
const surfaces = resolveSurfaceSelection(options as DisplayOptions | undefined, api, HOST);
|
|
110
|
+
const usageStore = createUsageStore(api, HOST);
|
|
111
|
+
const collapse = createCollapseState(api);
|
|
197
112
|
|
|
198
|
-
|
|
199
|
-
let legacyCollapsed = false;
|
|
113
|
+
const [activeProviderId, setActiveProviderId] = createSignal<string | undefined>(undefined);
|
|
200
114
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (!legacyCollapsed) return;
|
|
206
|
-
try {
|
|
207
|
-
api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
|
|
208
|
-
api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
|
|
209
|
-
api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
|
|
210
|
-
} catch {
|
|
211
|
-
// Collapse state is best-effort persistence only.
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
// Data boundary (auth.json -> Bearer usage fetch; throws nothing)
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
|
|
220
|
-
const controller = new AbortController();
|
|
221
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
222
|
-
try {
|
|
223
|
-
const response = await fetch(url, {
|
|
224
|
-
signal: controller.signal,
|
|
225
|
-
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
226
|
-
});
|
|
227
|
-
if (response.status === 401 || response.status === 403) return { __rejected: true };
|
|
228
|
-
if (!response.ok) return null;
|
|
229
|
-
try {
|
|
230
|
-
return (await response.json()) as unknown;
|
|
231
|
-
} catch {
|
|
232
|
-
return null;
|
|
115
|
+
const modelString = api.state?.config?.model;
|
|
116
|
+
if (typeof modelString === "string" && modelString.length > 0) {
|
|
117
|
+
const providerPart = modelString.split("/")[0];
|
|
118
|
+
if (providerPart !== undefined && providerPart.length > 0) setActiveProviderId(providerPart);
|
|
233
119
|
}
|
|
234
120
|
} catch {
|
|
235
|
-
|
|
236
|
-
} finally {
|
|
237
|
-
clearTimeout(timer);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
|
|
242
|
-
if (process.env.OPENCODE_GO_MOCK === "1") return mockSnapshot();
|
|
243
|
-
|
|
244
|
-
const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY) ?? readAuthJsonApiKey();
|
|
245
|
-
if (apiKey === null) return null;
|
|
246
|
-
|
|
247
|
-
const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
|
|
248
|
-
if (payload === null) return null;
|
|
249
|
-
// Mirror the server: a rejected key is a distinct unavailable snapshot
|
|
250
|
-
// (surfaced via `apiError`) rather than a silent null.
|
|
251
|
-
if (isRecord(payload) && payload.__rejected === true) {
|
|
252
|
-
return unavailableSnapshot("API key rejected (401/403)");
|
|
253
|
-
}
|
|
254
|
-
return extractSnapshotFromApiPayload(payload);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function logUsageError(api: TuiPluginApi, message: string): Promise<void> {
|
|
258
|
-
try {
|
|
259
|
-
await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
|
|
260
|
-
} catch {
|
|
261
|
-
// Logging is best-effort; the usage display must never break the host.
|
|
121
|
+
// State may not be ready; fall through to the event signal.
|
|
262
122
|
}
|
|
263
|
-
|
|
123
|
+
const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
|
|
264
124
|
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
migrateLegacyCollapsedFlag(api);
|
|
125
|
+
// The host's message store is where the per-model mix comes from, and it
|
|
126
|
+
// changes without the plan changing. Bumping this token on the same events
|
|
127
|
+
// that refresh the plan keeps the mix from going stale behind a host that
|
|
128
|
+
// reuses the element it was handed instead of re-invoking this subtree (see
|
|
129
|
+
// the reactivity note in `tui-shared`).
|
|
130
|
+
const [stateToken, setStateToken] = createSignal(0);
|
|
272
131
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
|
|
276
|
-
);
|
|
277
|
-
const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
|
|
278
|
-
readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
let cachedAt = 0;
|
|
282
|
-
let refreshInFlight = false;
|
|
283
|
-
|
|
284
|
-
async function refreshUsage(): Promise<void> {
|
|
285
|
-
if (refreshInFlight) return;
|
|
286
|
-
if (Date.now() - cachedAt < POLL_INTERVAL_MS && usageSnapshot() !== null) return;
|
|
287
|
-
refreshInFlight = true;
|
|
132
|
+
function readSessionMessages(sessionId: string): readonly unknown[] {
|
|
133
|
+
if (sessionId.length === 0) return [];
|
|
288
134
|
try {
|
|
289
|
-
|
|
290
|
-
if (snapshot === null) {
|
|
291
|
-
if (usageSnapshot() === null) {
|
|
292
|
-
await logUsageError(api, "Go usage unavailable (not configured or request failed)");
|
|
293
|
-
}
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
cachedAt = Date.now();
|
|
297
|
-
setUsageSnapshot(snapshot);
|
|
135
|
+
return api.state.session.messages(sessionId);
|
|
298
136
|
} catch {
|
|
299
|
-
|
|
300
|
-
} finally {
|
|
301
|
-
refreshInFlight = false;
|
|
137
|
+
return [];
|
|
302
138
|
}
|
|
303
139
|
}
|
|
304
140
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
141
|
+
// One model row: the display name the catalog gives it, then its weight bar
|
|
142
|
+
// and percent pushed to the right edge so the bars line up down the block.
|
|
143
|
+
function WeightedModelRow(props: { theme: TuiTheme; model: GoModelWeight }) {
|
|
144
|
+
const rounded = createMemo(() => Math.round(props.model.share));
|
|
145
|
+
const name = createMemo(() =>
|
|
146
|
+
modelDisplayName(
|
|
147
|
+
readModelDisplayName(api, props.model.providerID, props.model.modelID),
|
|
148
|
+
props.model.modelID,
|
|
149
|
+
OPENCODE_MODEL_NAME_MAX_CHARS,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
return (
|
|
153
|
+
<box flexDirection="row" gap={1} flexShrink={0}>
|
|
154
|
+
<text fg={props.theme.current.text} wrapMode="none" flexShrink={0}>
|
|
155
|
+
{name()}
|
|
156
|
+
</text>
|
|
157
|
+
<MeterBar
|
|
158
|
+
theme={props.theme}
|
|
159
|
+
percent={props.model.share}
|
|
160
|
+
severity={meterSeverityForPercent(rounded())}
|
|
161
|
+
/>
|
|
162
|
+
<text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
|
|
163
|
+
{formatPercentCell(rounded())}
|
|
164
|
+
</text>
|
|
165
|
+
</box>
|
|
166
|
+
);
|
|
313
167
|
}
|
|
314
168
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
169
|
+
// The model mix. Collapsed by default: the one-line summary is the cheap
|
|
170
|
+
// answer, and this block already leads a sidebar that the host's own Context
|
|
171
|
+
// panel follows. The count in the header is every Go model the session used,
|
|
172
|
+
// not the three rows below it, so a truncated ranking says so.
|
|
173
|
+
function TopGoModelsSection(props: { theme: TuiTheme; usage: () => SessionModelUsage | null }) {
|
|
174
|
+
const [isExpanded, setIsExpanded] = createSignal<boolean>(false);
|
|
175
|
+
const weights = createMemo(() => weightGoModels(props.usage()?.models ?? []));
|
|
176
|
+
const rows = (): GoModelWeight[] => weights().models;
|
|
177
|
+
// The collapsed line names the same models the expanded rows do, in as few
|
|
178
|
+
// cells as the sidebar has.
|
|
179
|
+
const mix = createMemo(() =>
|
|
180
|
+
buildModelMixSummary(
|
|
181
|
+
rows().map((model) => ({
|
|
182
|
+
name: shortModelName(
|
|
183
|
+
modelDisplayName(
|
|
184
|
+
readModelDisplayName(api, model.providerID, model.modelID),
|
|
185
|
+
model.modelID,
|
|
186
|
+
OPENCODE_MODEL_NAME_MAX_CHARS,
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
share: model.share,
|
|
190
|
+
})),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
324
193
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
api,
|
|
331
|
-
snapshot.apiError ? `Go usage unavailable (${snapshot.apiError})` : "Go usage snapshot unavailable",
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
return (
|
|
336
|
-
<box flexDirection="column">
|
|
337
|
-
<text fg={props.theme.current.text}>
|
|
338
|
-
<b>Go Usage</b>
|
|
339
|
-
</text>
|
|
340
|
-
<Show
|
|
341
|
-
when={usageSnapshot()}
|
|
342
|
-
fallback={
|
|
194
|
+
const body = createMemo(() => (
|
|
195
|
+
<box flexDirection="column" gap={1} paddingTop={1} paddingLeft={2}>
|
|
196
|
+
<For each={rows()}>{(model) => <WeightedModelRow theme={props.theme} model={model} />}</For>
|
|
197
|
+
<For each={buildGoModelFooters(weights())}>
|
|
198
|
+
{(line) => (
|
|
343
199
|
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
344
|
-
|
|
200
|
+
{line}
|
|
345
201
|
</text>
|
|
346
|
-
}
|
|
347
|
-
>
|
|
348
|
-
{(snapshot) => (
|
|
349
|
-
<For each={buildUsageRows(snapshot())}>
|
|
350
|
-
{(row) => (
|
|
351
|
-
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
352
|
-
{row.label} {row.value}
|
|
353
|
-
</text>
|
|
354
|
-
)}
|
|
355
|
-
</For>
|
|
356
202
|
)}
|
|
357
|
-
</
|
|
203
|
+
</For>
|
|
358
204
|
</box>
|
|
359
|
-
);
|
|
205
|
+
));
|
|
206
|
+
|
|
207
|
+
// The whole section is one function child, because two rules apply here and
|
|
208
|
+
// both are about the same thing: the host reuses the element it was handed,
|
|
209
|
+
// so a plain value is read once at mount. That is why the conditions below
|
|
210
|
+
// are read INSIDE this memo (re-running it is what re-reads them) and why
|
|
211
|
+
// `Show` is given a value rather than an accessor -- solid's `Show` memoizes
|
|
212
|
+
// `when` and never calls it, so an accessor here is a function object, and a
|
|
213
|
+
// function object is always truthy. The real host caught exactly that: a
|
|
214
|
+
// session with no assistant messages rendered `Top Go models (0)` with a
|
|
215
|
+
// caret that opened onto nothing.
|
|
216
|
+
const section = createMemo(() => {
|
|
217
|
+
// No models yet is not a fold: there is nothing to fold, and a caret that
|
|
218
|
+
// opens onto an empty box reads as a broken section.
|
|
219
|
+
if (weights().goModels === 0) {
|
|
220
|
+
return (
|
|
221
|
+
<box flexDirection="column">
|
|
222
|
+
<text fg={props.theme.current.text} wrapMode="none">
|
|
223
|
+
<b>{TOP_GO_MODELS_LABEL}</b>
|
|
224
|
+
</text>
|
|
225
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
226
|
+
{MODELS_EMPTY_LABEL}
|
|
227
|
+
</text>
|
|
228
|
+
</box>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return (
|
|
232
|
+
<box flexDirection="column">
|
|
233
|
+
<CollapsibleSection
|
|
234
|
+
theme={props.theme}
|
|
235
|
+
label={TOP_GO_MODELS_LABEL}
|
|
236
|
+
count={() => weights().goModels}
|
|
237
|
+
expanded={isExpanded}
|
|
238
|
+
onToggle={() => setIsExpanded((current) => !current)}
|
|
239
|
+
body={body}
|
|
240
|
+
/>
|
|
241
|
+
<Show when={!isExpanded()}>
|
|
242
|
+
<text fg={props.theme.current.textMuted} wrapMode="none" paddingLeft={2}>
|
|
243
|
+
{reactiveChild(mix)}
|
|
244
|
+
</text>
|
|
245
|
+
</Show>
|
|
246
|
+
</box>
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return <box flexDirection="column">{reactiveChild(section)}</box>;
|
|
360
251
|
}
|
|
361
252
|
|
|
362
|
-
function
|
|
253
|
+
function GoSidebarPanel(props: { theme: TuiTheme; sessionId: string }) {
|
|
254
|
+
const planResetLine = createMemo(() => {
|
|
255
|
+
const snapshot = usageStore.snapshot();
|
|
256
|
+
if (snapshot === null || snapshot.source === "unavailable") return null;
|
|
257
|
+
return relevantReset(snapshot);
|
|
258
|
+
});
|
|
259
|
+
const sessionUsage = createMemo(() => {
|
|
260
|
+
stateToken();
|
|
261
|
+
return aggregateModelUsageFromMessages(readSessionMessages(props.sessionId));
|
|
262
|
+
});
|
|
363
263
|
return (
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
264
|
+
<box flexDirection="column" gap={1}>
|
|
265
|
+
<GoUsageBlock
|
|
266
|
+
api={api}
|
|
267
|
+
theme={props.theme}
|
|
268
|
+
snapshot={usageStore.snapshot}
|
|
269
|
+
resetLine={planResetLine}
|
|
270
|
+
/>
|
|
271
|
+
<TopGoModelsSection theme={props.theme} usage={sessionUsage} />
|
|
272
|
+
</box>
|
|
370
273
|
);
|
|
371
274
|
}
|
|
372
275
|
|
|
@@ -375,69 +278,152 @@ const goUsageTui: TuiPlugin = async (api, options) => {
|
|
|
375
278
|
// unregister API, so there is nothing to dispose here (the slot dies
|
|
376
279
|
// with the host). Interval/event/command teardown below is the full
|
|
377
280
|
// dispose path.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
281
|
+
try {
|
|
282
|
+
api.slots.register({
|
|
283
|
+
order: SLOT_ORDER,
|
|
284
|
+
slots: {
|
|
285
|
+
sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
|
|
286
|
+
// Individually guarded: a later render must never throw into the host.
|
|
287
|
+
try {
|
|
288
|
+
if (props.session_id.length === 0) return null;
|
|
289
|
+
if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
|
|
290
|
+
if (api.route.current.name !== "session") return null;
|
|
291
|
+
if (collapse.isSidebarCollapsed()) return null;
|
|
292
|
+
return <GoSidebarPanel theme={ctx.theme} sessionId={props.session_id} />;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
386
297
|
},
|
|
387
|
-
}
|
|
388
|
-
}
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
// Additive slot: a registration failure must not abort the plugin.
|
|
301
|
+
}
|
|
389
302
|
}
|
|
390
303
|
|
|
391
304
|
if (surfaces.statusline) {
|
|
392
305
|
// Host-owned slot (see sidebar note above): no unregister API exists,
|
|
393
306
|
// so disposal is a no-op for slots.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
307
|
+
try {
|
|
308
|
+
api.slots.register({
|
|
309
|
+
order: SLOT_ORDER,
|
|
310
|
+
slots: {
|
|
311
|
+
session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
|
|
312
|
+
// Individually guarded: a later render must never throw into the host.
|
|
313
|
+
try {
|
|
314
|
+
if (props.session_id.length === 0) return null;
|
|
315
|
+
if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
|
|
316
|
+
if (collapse.isStatuslineCollapsed()) return null;
|
|
317
|
+
return <GoStatusline snapshot={usageStore.snapshot} />;
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
},
|
|
401
322
|
},
|
|
402
|
-
}
|
|
403
|
-
}
|
|
323
|
+
});
|
|
324
|
+
} catch {
|
|
325
|
+
// Additive slot: a registration failure must not abort the plugin.
|
|
326
|
+
}
|
|
404
327
|
}
|
|
405
328
|
|
|
406
329
|
// `api.command` is a deprecated legacy shim that hosts may omit; guard so
|
|
407
330
|
// the plugin still initializes and disposes safely without it.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
331
|
+
let unregisterToggleCommand: () => void = () => {};
|
|
332
|
+
try {
|
|
333
|
+
const unregister = api.command?.register(() => [
|
|
334
|
+
{
|
|
335
|
+
title: "Go usage: toggle sidebar",
|
|
336
|
+
value: "oc-go-usage-display.toggle-sidebar",
|
|
337
|
+
category: "Go",
|
|
338
|
+
onSelect: () => collapse.toggleSidebar(),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
title: "Go usage: toggle statusline",
|
|
342
|
+
value: "oc-go-usage-display.toggle-statusline",
|
|
343
|
+
category: "Go",
|
|
344
|
+
onSelect: () => collapse.toggleStatusline(),
|
|
345
|
+
},
|
|
346
|
+
]);
|
|
347
|
+
if (typeof unregister === "function") unregisterToggleCommand = unregister;
|
|
348
|
+
} catch {
|
|
349
|
+
// Legacy command registration is optional; ignore failures.
|
|
350
|
+
}
|
|
422
351
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
352
|
+
let unsubscribeSession: () => void = () => {};
|
|
353
|
+
let unsubscribeMessage: () => void = () => {};
|
|
354
|
+
try {
|
|
355
|
+
const unsubscribe = api.event.on("session.updated", (event) => {
|
|
356
|
+
const providerId = event?.properties?.info?.model?.providerID;
|
|
357
|
+
if (providerId !== undefined) setActiveProviderId(providerId);
|
|
358
|
+
usageStore.refreshSafely(0);
|
|
359
|
+
});
|
|
360
|
+
if (typeof unsubscribe === "function") unsubscribeSession = unsubscribe;
|
|
361
|
+
} catch {
|
|
362
|
+
// Event subscription is additive; a failure must not abort the plugin.
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const unsubscribe = api.event.on("message.updated", () => {
|
|
366
|
+
usageStore.refreshSafely(EVENT_TTL_MS);
|
|
367
|
+
// The model's share of the session changes with every finished message,
|
|
368
|
+
// not only when the plan does.
|
|
369
|
+
setStateToken((current) => current + 1);
|
|
370
|
+
});
|
|
371
|
+
if (typeof unsubscribe === "function") unsubscribeMessage = unsubscribe;
|
|
372
|
+
} catch {
|
|
373
|
+
// Event subscription is additive; a failure must not abort the plugin.
|
|
374
|
+
}
|
|
432
375
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
376
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
377
|
+
try {
|
|
378
|
+
pollTimer = setInterval(() => usageStore.refreshSafely(), POLL_INTERVAL_MS);
|
|
379
|
+
} catch {
|
|
380
|
+
// No poll timer: the on-demand refresh below still runs.
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
api.lifecycle.onDispose(() => {
|
|
385
|
+
// Each teardown step is isolated: one throwing unsubscribe must not
|
|
386
|
+
// prevent the rest (or leak into the host's dispose pass).
|
|
387
|
+
if (pollTimer !== null) {
|
|
388
|
+
try {
|
|
389
|
+
clearInterval(pollTimer);
|
|
390
|
+
} catch {
|
|
391
|
+
// noop
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
|
|
395
|
+
try {
|
|
396
|
+
teardown();
|
|
397
|
+
} catch {
|
|
398
|
+
// noop
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
} catch {
|
|
403
|
+
// Lifecycle registration is best-effort; never leak a timer into the host.
|
|
404
|
+
if (pollTimer !== null) {
|
|
405
|
+
try {
|
|
406
|
+
clearInterval(pollTimer);
|
|
407
|
+
} catch {
|
|
408
|
+
// noop
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await usageStore.refresh();
|
|
414
|
+
}
|
|
439
415
|
|
|
440
|
-
|
|
416
|
+
const goUsageTui: TuiPlugin = async (api, options) => {
|
|
417
|
+
try {
|
|
418
|
+
await initializeTui(api, options);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
// Best-effort and non-blocking: never delay plugin resolution on the host
|
|
421
|
+
// log and never let an initialization error reject into the host.
|
|
422
|
+
void logUsageError(
|
|
423
|
+
api,
|
|
424
|
+
`Go usage TUI failed to initialize: ${errorMessage(error)}; continuing without display`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
441
427
|
};
|
|
442
428
|
|
|
443
429
|
export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;
|