oc-go-usage-display 1.0.2 → 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 +249 -68
- package/bin/lib.js +433 -29
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +38 -0
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +33 -42
- 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 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +258 -278
- 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 +96 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +575 -0
- package/dist/shared.js.map +1 -0
- 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.map +1 -1
- package/dist/tui.js +226 -377
- 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 +47 -13
- package/src/helpers.ts +733 -0
- package/src/index.ts +331 -304
- package/src/shared.ts +696 -0
- package/src/tui-shared.tsx +705 -0
- package/src/tui.kilo.tsx +816 -0
- package/src/tui.tsx +356 -465
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%`), 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,481 +60,370 @@ import type {
|
|
|
58
60
|
TuiSlotContext,
|
|
59
61
|
TuiTheme,
|
|
60
62
|
} from "@opencode-ai/plugin/tui";
|
|
61
|
-
import { For, Show,
|
|
62
|
-
import
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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";
|
|
75
|
+
import {
|
|
76
|
+
aggregateModelUsageFromMessages,
|
|
77
|
+
errorMessage,
|
|
78
|
+
MODELS_EMPTY_LABEL,
|
|
79
|
+
OPENCODE_MODEL_NAME_MAX_CHARS,
|
|
80
|
+
TOP_GO_MODELS_LABEL,
|
|
81
|
+
} from "./shared.js";
|
|
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;
|
|
73
103
|
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
104
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
}
|
|
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);
|
|
163
112
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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}` : ""}` });
|
|
113
|
+
const [activeProviderId, setActiveProviderId] = createSignal<string | undefined>(undefined);
|
|
114
|
+
try {
|
|
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);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// State may not be ready; fall through to the event signal.
|
|
189
122
|
}
|
|
190
|
-
|
|
191
|
-
if (snapshot.monthly !== null) rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
|
|
192
|
-
return rows;
|
|
193
|
-
}
|
|
123
|
+
const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
|
|
194
124
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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);
|
|
199
131
|
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return
|
|
132
|
+
function readSessionMessages(sessionId: string): readonly unknown[] {
|
|
133
|
+
if (sessionId.length === 0) return [];
|
|
134
|
+
try {
|
|
135
|
+
return api.state.session.messages(sessionId);
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
206
138
|
}
|
|
207
|
-
if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
|
|
208
139
|
}
|
|
209
140
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
);
|
|
214
167
|
}
|
|
215
168
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
);
|
|
220
193
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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) => (
|
|
199
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
200
|
+
{line}
|
|
201
|
+
</text>
|
|
202
|
+
)}
|
|
203
|
+
</For>
|
|
204
|
+
</box>
|
|
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
|
+
});
|
|
229
249
|
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
return api.kv.get<boolean>(key, false) === true;
|
|
233
|
-
} catch {
|
|
234
|
-
return false;
|
|
250
|
+
return <box flexDirection="column">{reactiveChild(section)}</box>;
|
|
235
251
|
}
|
|
236
|
-
}
|
|
237
252
|
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
});
|
|
263
|
+
return (
|
|
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>
|
|
273
|
+
);
|
|
252
274
|
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// Data boundary (auth.json -> Bearer usage fetch; throws nothing)
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
275
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
if (surfaces.sidebar) {
|
|
277
|
+
// Host-owned slot: `register` returns an id but the SDK exposes no
|
|
278
|
+
// unregister API, so there is nothing to dispose here (the slot dies
|
|
279
|
+
// with the host). Interval/event/command teardown below is the full
|
|
280
|
+
// dispose path.
|
|
262
281
|
try {
|
|
263
|
-
|
|
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
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
264
299
|
} catch {
|
|
265
|
-
|
|
300
|
+
// Additive slot: a registration failure must not abort the plugin.
|
|
266
301
|
}
|
|
267
|
-
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (surfaces.statusline) {
|
|
305
|
+
// Host-owned slot (see sidebar note above): no unregister API exists,
|
|
306
|
+
// so disposal is a no-op for slots.
|
|
268
307
|
try {
|
|
269
|
-
|
|
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
|
+
},
|
|
322
|
+
},
|
|
323
|
+
});
|
|
270
324
|
} catch {
|
|
271
|
-
|
|
325
|
+
// Additive slot: a registration failure must not abort the plugin.
|
|
272
326
|
}
|
|
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
327
|
}
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
328
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
}
|
|
329
|
+
// `api.command` is a deprecated legacy shim that hosts may omit; guard so
|
|
330
|
+
// the plugin still initializes and disposes safely without it.
|
|
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.
|
|
315
350
|
}
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
351
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
352
|
+
let unsubscribeSession: () => void = () => {};
|
|
353
|
+
let unsubscribeMessage: () => void = () => {};
|
|
322
354
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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);
|
|
326
359
|
});
|
|
327
|
-
if (
|
|
328
|
-
if (!response.ok) return null;
|
|
329
|
-
try {
|
|
330
|
-
return (await response.json()) as unknown;
|
|
331
|
-
} catch {
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
360
|
+
if (typeof unsubscribe === "function") unsubscribeSession = unsubscribe;
|
|
334
361
|
} catch {
|
|
335
|
-
|
|
336
|
-
} finally {
|
|
337
|
-
clearTimeout(timer);
|
|
362
|
+
// Event subscription is additive; a failure must not abort the plugin.
|
|
338
363
|
}
|
|
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
364
|
try {
|
|
365
|
-
|
|
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;
|
|
366
372
|
} catch {
|
|
367
|
-
//
|
|
373
|
+
// Event subscription is additive; a failure must not abort the plugin.
|
|
368
374
|
}
|
|
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
375
|
|
|
387
|
-
let
|
|
388
|
-
|
|
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
|
+
}
|
|
389
382
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
399
392
|
}
|
|
400
|
-
return;
|
|
401
393
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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");
|
|
394
|
+
for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
|
|
395
|
+
try {
|
|
396
|
+
teardown();
|
|
397
|
+
} catch {
|
|
398
|
+
// noop
|
|
399
|
+
}
|
|
436
400
|
}
|
|
437
401
|
});
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
);
|
|
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
|
+
}
|
|
474
411
|
}
|
|
475
412
|
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
}
|
|
413
|
+
await usageStore.refresh();
|
|
414
|
+
}
|
|
489
415
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
});
|
|
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
|
+
);
|
|
501
426
|
}
|
|
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
427
|
};
|
|
537
428
|
|
|
538
429
|
export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;
|