@leo000001/opencode-quota-sidebar 3.0.10 → 4.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/CHANGELOG.md +0 -1
- package/README.md +163 -42
- package/README.zh-CN.md +163 -42
- package/SECURITY.md +1 -1
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +354 -0
- package/dist/cli_render.d.ts +17 -0
- package/dist/cli_render.js +292 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.js +2 -2
- package/dist/format.d.ts +4 -0
- package/dist/format.js +391 -49
- package/dist/history_messages.d.ts +8 -0
- package/dist/history_messages.js +157 -0
- package/dist/history_usage.d.ts +93 -0
- package/dist/history_usage.js +251 -0
- package/dist/index.js +29 -4
- package/dist/period.d.ts +29 -1
- package/dist/period.js +187 -9
- package/dist/provider_catalog.d.ts +8 -0
- package/dist/provider_catalog.js +68 -0
- package/dist/providers/core/anthropic.d.ts +1 -1
- package/dist/providers/core/anthropic.js +69 -45
- package/dist/providers/core/openai.js +38 -2
- package/dist/providers/index.d.ts +1 -2
- package/dist/providers/index.js +1 -3
- package/dist/quota.d.ts +4 -2
- package/dist/quota.js +18 -21
- package/dist/quota_render.d.ts +1 -1
- package/dist/quota_render.js +23 -24
- package/dist/quota_service.d.ts +1 -0
- package/dist/quota_service.js +151 -19
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +4 -4
- package/dist/storage_dates.d.ts +1 -1
- package/dist/storage_dates.js +8 -5
- package/dist/storage_parse.js +23 -1
- package/dist/supported_quota.d.ts +4 -0
- package/dist/supported_quota.js +36 -0
- package/dist/title.js +21 -10
- package/dist/tools.d.ts +14 -3
- package/dist/tools.js +54 -2
- package/dist/tui.tsx +17 -6
- package/dist/tui_helpers.js +11 -6
- package/dist/types.d.ts +8 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +93 -9
- package/dist/usage_service.d.ts +4 -1
- package/dist/usage_service.js +193 -189
- package/package.json +4 -1
- package/quota-sidebar.config.example.json +36 -45
- package/dist/providers/third_party/xyai.d.ts +0 -2
- package/dist/providers/third_party/xyai.js +0 -348
package/dist/cli.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createOpencodeClient } from '@opencode-ai/sdk/client';
|
|
6
|
+
import { cliCurrentLabel, renderCliDashboard, renderCliHistoryDashboard, } from './cli_render.js';
|
|
7
|
+
import { createQuotaRuntime } from './quota.js';
|
|
8
|
+
import { createQuotaService } from './quota_service.js';
|
|
9
|
+
import { sinceFromLast } from './period.js';
|
|
10
|
+
import { authFilePath, loadConfig, loadState,
|
|
11
|
+
// reused for CLI-local config layering
|
|
12
|
+
quotaConfigPaths, resolveOpencodeDataDir, stateFilePath, } from './storage.js';
|
|
13
|
+
import { filterHistoryProvidersForDisplay, filterUsageProvidersForDisplay, listCurrentProviderIDs, } from './provider_catalog.js';
|
|
14
|
+
import { createUsageService } from './usage_service.js';
|
|
15
|
+
const DEFAULT_OPENCODE_BASE_URL = 'http://localhost:4096';
|
|
16
|
+
const CLI_SERVER_TIMEOUT_MS = 10_000;
|
|
17
|
+
const HELP_TEXT = `opencode-quota
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
opencode-quota day
|
|
21
|
+
opencode-quota day 7
|
|
22
|
+
opencode-quota day --since 2026-04-01
|
|
23
|
+
opencode-quota week
|
|
24
|
+
opencode-quota week 8
|
|
25
|
+
opencode-quota week --since 2026-04-01
|
|
26
|
+
opencode-quota month
|
|
27
|
+
opencode-quota month 6
|
|
28
|
+
opencode-quota month --since 2026-01
|
|
29
|
+
|
|
30
|
+
Notes:
|
|
31
|
+
day with no extra args means the current natural day
|
|
32
|
+
week with no extra args means the current natural week starting Monday
|
|
33
|
+
month with no extra args means the current natural month
|
|
34
|
+
positional integers map to last=<N>
|
|
35
|
+
--since accepts YYYY-MM-DD for day/week and YYYY-MM for month
|
|
36
|
+
`;
|
|
37
|
+
function isPositiveInteger(value) {
|
|
38
|
+
return /^\d+$/.test(value) && Number(value) > 0;
|
|
39
|
+
}
|
|
40
|
+
function validSinceForPeriod(period, value) {
|
|
41
|
+
if (period === 'month')
|
|
42
|
+
return /^\d{4}-\d{2}$/.test(value);
|
|
43
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
44
|
+
}
|
|
45
|
+
export function parseCliArgs(argv) {
|
|
46
|
+
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
47
|
+
throw new Error(HELP_TEXT);
|
|
48
|
+
}
|
|
49
|
+
const [periodArg, ...rest] = argv;
|
|
50
|
+
if (periodArg !== 'day' && periodArg !== 'week' && periodArg !== 'month') {
|
|
51
|
+
throw new Error(`Unknown period: ${periodArg}\n\n${HELP_TEXT}`);
|
|
52
|
+
}
|
|
53
|
+
let since;
|
|
54
|
+
let last;
|
|
55
|
+
const positional = [];
|
|
56
|
+
for (let index = 0; index < rest.length; index++) {
|
|
57
|
+
const arg = rest[index];
|
|
58
|
+
if (arg === '--since') {
|
|
59
|
+
const value = rest[index + 1];
|
|
60
|
+
if (!value) {
|
|
61
|
+
throw new Error('Missing value for --since');
|
|
62
|
+
}
|
|
63
|
+
since = value.trim();
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === '--last') {
|
|
68
|
+
const value = rest[index + 1];
|
|
69
|
+
if (!value || !isPositiveInteger(value)) {
|
|
70
|
+
throw new Error('--last must be a positive integer');
|
|
71
|
+
}
|
|
72
|
+
last = Number(value);
|
|
73
|
+
index += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
positional.push(arg);
|
|
77
|
+
}
|
|
78
|
+
if (positional.length > 1) {
|
|
79
|
+
throw new Error(`Too many positional arguments\n\n${HELP_TEXT}`);
|
|
80
|
+
}
|
|
81
|
+
if (positional.length === 1) {
|
|
82
|
+
const value = positional[0].trim();
|
|
83
|
+
if (isPositiveInteger(value)) {
|
|
84
|
+
last = Number(value);
|
|
85
|
+
}
|
|
86
|
+
else if (validSinceForPeriod(periodArg, value)) {
|
|
87
|
+
since = value;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new Error(periodArg === 'month'
|
|
91
|
+
? 'Expected a positive integer or YYYY-MM'
|
|
92
|
+
: 'Expected a positive integer or YYYY-MM-DD');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (since && last !== undefined) {
|
|
96
|
+
throw new Error('Cannot use both since and last');
|
|
97
|
+
}
|
|
98
|
+
if (since && !validSinceForPeriod(periodArg, since)) {
|
|
99
|
+
throw new Error(periodArg === 'month'
|
|
100
|
+
? '--since must use YYYY-MM for month'
|
|
101
|
+
: '--since must use YYYY-MM-DD for day/week');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
period: periodArg,
|
|
105
|
+
...(since ? { since } : {}),
|
|
106
|
+
...(last !== undefined ? { last } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function cliBaseUrl() {
|
|
110
|
+
const override = process.env.OPENCODE_BASE_URL?.trim();
|
|
111
|
+
return override || DEFAULT_OPENCODE_BASE_URL;
|
|
112
|
+
}
|
|
113
|
+
function isDefaultBaseUrl() {
|
|
114
|
+
return !process.env.OPENCODE_BASE_URL?.trim();
|
|
115
|
+
}
|
|
116
|
+
export function cliServerCommandCandidates(platform = process.platform) {
|
|
117
|
+
const directArgs = ['serve', '--hostname=127.0.0.1', '--port=4096'];
|
|
118
|
+
if (platform === 'win32') {
|
|
119
|
+
return [
|
|
120
|
+
{ command: 'opencode.cmd', args: directArgs },
|
|
121
|
+
{
|
|
122
|
+
command: 'opencode serve --hostname=127.0.0.1 --port=4096',
|
|
123
|
+
args: [],
|
|
124
|
+
shell: true,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
command: 'bash',
|
|
128
|
+
args: ['-lc', 'opencode serve --hostname=127.0.0.1 --port=4096'],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
return [{ command: 'opencode', args: directArgs }];
|
|
133
|
+
}
|
|
134
|
+
async function tryStartCliOpencodeServer(candidate) {
|
|
135
|
+
let proc;
|
|
136
|
+
try {
|
|
137
|
+
proc = spawn(candidate.command, candidate.args, {
|
|
138
|
+
env: process.env,
|
|
139
|
+
shell: candidate.shell ?? false,
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const code = error.code;
|
|
145
|
+
throw {
|
|
146
|
+
error,
|
|
147
|
+
output: '',
|
|
148
|
+
recoverable: code === 'ENOENT' || code === 'EINVAL',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const url = await new Promise((resolve, reject) => {
|
|
152
|
+
const id = setTimeout(() => {
|
|
153
|
+
reject(new Error(`Timeout waiting for OpenCode server to start after ${CLI_SERVER_TIMEOUT_MS}ms`));
|
|
154
|
+
}, CLI_SERVER_TIMEOUT_MS);
|
|
155
|
+
let output = '';
|
|
156
|
+
let settled = false;
|
|
157
|
+
const inspect = (chunk) => {
|
|
158
|
+
output += chunk.toString();
|
|
159
|
+
const lines = output.split('\n');
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (!line.startsWith('opencode server listening'))
|
|
162
|
+
continue;
|
|
163
|
+
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
|
164
|
+
if (!match)
|
|
165
|
+
continue;
|
|
166
|
+
clearTimeout(id);
|
|
167
|
+
settled = true;
|
|
168
|
+
resolve(match[1]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
proc.stdout?.on('data', inspect);
|
|
173
|
+
proc.stderr?.on('data', inspect);
|
|
174
|
+
proc.on('error', (error) => {
|
|
175
|
+
clearTimeout(id);
|
|
176
|
+
const code = error.code;
|
|
177
|
+
reject({
|
|
178
|
+
error,
|
|
179
|
+
output,
|
|
180
|
+
recoverable: code === 'ENOENT' || code === 'EINVAL',
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
proc.on('exit', (code) => {
|
|
184
|
+
if (settled)
|
|
185
|
+
return;
|
|
186
|
+
clearTimeout(id);
|
|
187
|
+
let message = `OpenCode server exited with code ${code}`;
|
|
188
|
+
if (output.trim())
|
|
189
|
+
message += `\n${output}`;
|
|
190
|
+
const recoverable = /not recognized as an internal or external command/i.test(output) ||
|
|
191
|
+
/command not found/i.test(output);
|
|
192
|
+
reject({ error: new Error(message), output, recoverable });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
url,
|
|
197
|
+
close: () => proc.kill(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async function startCliOpencodeServer() {
|
|
201
|
+
const candidates = cliServerCommandCandidates();
|
|
202
|
+
let lastError;
|
|
203
|
+
for (const candidate of candidates) {
|
|
204
|
+
try {
|
|
205
|
+
return await tryStartCliOpencodeServer(candidate);
|
|
206
|
+
}
|
|
207
|
+
catch (failure) {
|
|
208
|
+
lastError = failure;
|
|
209
|
+
const recoverable = typeof failure === 'object' &&
|
|
210
|
+
failure !== null &&
|
|
211
|
+
'recoverable' in failure &&
|
|
212
|
+
failure.recoverable === true;
|
|
213
|
+
if (!recoverable) {
|
|
214
|
+
const error = typeof failure === 'object' && failure !== null && 'error' in failure
|
|
215
|
+
? failure.error
|
|
216
|
+
: failure;
|
|
217
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const error = typeof lastError === 'object' && lastError !== null && 'error' in lastError
|
|
222
|
+
? lastError.error
|
|
223
|
+
: lastError;
|
|
224
|
+
throw error instanceof Error
|
|
225
|
+
? error
|
|
226
|
+
: new Error('Failed to start OpenCode server');
|
|
227
|
+
}
|
|
228
|
+
async function resolvePathInfo(directory) {
|
|
229
|
+
const connect = async (baseUrl) => {
|
|
230
|
+
const client = createOpencodeClient({ directory, baseUrl });
|
|
231
|
+
const response = await client.path.get({
|
|
232
|
+
query: { directory },
|
|
233
|
+
throwOnError: true,
|
|
234
|
+
});
|
|
235
|
+
const data = response.data;
|
|
236
|
+
return {
|
|
237
|
+
client,
|
|
238
|
+
worktree: data.worktree || directory,
|
|
239
|
+
directory: data.directory || directory,
|
|
240
|
+
close: () => { },
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
try {
|
|
244
|
+
return await connect(cliBaseUrl());
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!isDefaultBaseUrl()) {
|
|
248
|
+
throw new Error(`Failed to connect to OpenCode API at ${cliBaseUrl()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
const server = await startCliOpencodeServer();
|
|
251
|
+
const client = createOpencodeClient({
|
|
252
|
+
directory,
|
|
253
|
+
baseUrl: server.url,
|
|
254
|
+
});
|
|
255
|
+
const response = await client.path.get({
|
|
256
|
+
query: { directory },
|
|
257
|
+
throwOnError: true,
|
|
258
|
+
});
|
|
259
|
+
const data = response.data;
|
|
260
|
+
return {
|
|
261
|
+
client,
|
|
262
|
+
worktree: data.worktree || directory,
|
|
263
|
+
directory: data.directory || directory,
|
|
264
|
+
close: () => server.close(),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export async function runCli(argv) {
|
|
269
|
+
const command = parseCliArgs(argv);
|
|
270
|
+
const cwd = process.cwd();
|
|
271
|
+
const connection = await resolvePathInfo(cwd);
|
|
272
|
+
try {
|
|
273
|
+
const { client, worktree, directory } = connection;
|
|
274
|
+
const config = await loadConfig(quotaConfigPaths(worktree, directory));
|
|
275
|
+
const dataDir = resolveOpencodeDataDir();
|
|
276
|
+
const statePath = stateFilePath(dataDir);
|
|
277
|
+
const authPath = authFilePath(dataDir);
|
|
278
|
+
const state = await loadState(statePath);
|
|
279
|
+
const quotaService = createQuotaService({
|
|
280
|
+
quotaRuntime: createQuotaRuntime(),
|
|
281
|
+
config,
|
|
282
|
+
state,
|
|
283
|
+
authPath,
|
|
284
|
+
client: client,
|
|
285
|
+
directory,
|
|
286
|
+
scheduleSave: () => { },
|
|
287
|
+
});
|
|
288
|
+
const usageService = createUsageService({
|
|
289
|
+
state,
|
|
290
|
+
config,
|
|
291
|
+
statePath,
|
|
292
|
+
client: client,
|
|
293
|
+
directory,
|
|
294
|
+
persistence: {
|
|
295
|
+
markDirty: () => { },
|
|
296
|
+
scheduleSave: () => { },
|
|
297
|
+
flushSave: async () => { },
|
|
298
|
+
},
|
|
299
|
+
descendantsResolver: {
|
|
300
|
+
listDescendantSessionIDs: async () => [],
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const quotas = await quotaService.getQuotaSnapshots([], {
|
|
304
|
+
allowDefault: true,
|
|
305
|
+
});
|
|
306
|
+
const allowedProviderIDs = await listCurrentProviderIDs({
|
|
307
|
+
client,
|
|
308
|
+
directory,
|
|
309
|
+
}).catch(() => new Set());
|
|
310
|
+
if (command.since || command.last !== undefined) {
|
|
311
|
+
const resolvedSince = command.since || sinceFromLast(command.period, command.last);
|
|
312
|
+
const historyRaw = await usageService.summarizeHistoryUsage(command.period, resolvedSince);
|
|
313
|
+
const history = filterHistoryProvidersForDisplay(historyRaw, allowedProviderIDs);
|
|
314
|
+
return renderCliHistoryDashboard({
|
|
315
|
+
result: history,
|
|
316
|
+
quotas,
|
|
317
|
+
width: 80,
|
|
318
|
+
showCost: config.sidebar.showCost,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const usageRaw = await usageService.summarizeForTool(command.period, '', false);
|
|
322
|
+
const usage = filterUsageProvidersForDisplay(usageRaw, allowedProviderIDs);
|
|
323
|
+
return renderCliDashboard({
|
|
324
|
+
label: cliCurrentLabel(command.period),
|
|
325
|
+
usage,
|
|
326
|
+
quotas,
|
|
327
|
+
width: 80,
|
|
328
|
+
showCost: config.sidebar.showCost,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
connection.close();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
export function cliExitCodeForError(message) {
|
|
336
|
+
return message === HELP_TEXT ? 0 : 1;
|
|
337
|
+
}
|
|
338
|
+
async function main() {
|
|
339
|
+
try {
|
|
340
|
+
const output = await runCli(process.argv.slice(2));
|
|
341
|
+
process.stdout.write(`${output}\n`);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
345
|
+
const exitCode = cliExitCodeForError(message);
|
|
346
|
+
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
347
|
+
stream.write(`${message}\n`);
|
|
348
|
+
process.exitCode = exitCode;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (process.argv[1] &&
|
|
352
|
+
path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
|
|
353
|
+
void main();
|
|
354
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QuotaSnapshot } from './types.js';
|
|
2
|
+
import { type UsageSummary } from './usage.js';
|
|
3
|
+
import type { HistoryUsageResult } from './usage_service.js';
|
|
4
|
+
export declare function renderCliDashboard(input: {
|
|
5
|
+
label: string;
|
|
6
|
+
usage: UsageSummary;
|
|
7
|
+
quotas: QuotaSnapshot[];
|
|
8
|
+
width?: number;
|
|
9
|
+
showCost?: boolean;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function renderCliHistoryDashboard(input: {
|
|
12
|
+
result: HistoryUsageResult;
|
|
13
|
+
quotas: QuotaSnapshot[];
|
|
14
|
+
width?: number;
|
|
15
|
+
showCost?: boolean;
|
|
16
|
+
}): string;
|
|
17
|
+
export declare function cliCurrentLabel(period: 'day' | 'week' | 'month'): "Today" | "This Week" | "This Month";
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { canonicalProviderID, collapseQuotaSnapshots, quotaDisplayLabel, } from './quota_render.js';
|
|
2
|
+
import { getCacheCoverageMetrics, getProviderCacheCoverageMetrics, } from './usage.js';
|
|
3
|
+
function shortNumber(value, decimals = 1) {
|
|
4
|
+
if (!Number.isFinite(value) || value < 0)
|
|
5
|
+
return '0';
|
|
6
|
+
if (value >= 1_000_000)
|
|
7
|
+
return `${(value / 1_000_000).toFixed(decimals)}m`;
|
|
8
|
+
if (value >= 1000) {
|
|
9
|
+
const k = value / 1000;
|
|
10
|
+
if (Number(k.toFixed(decimals)) >= 1000) {
|
|
11
|
+
return `${(value / 1_000_000).toFixed(decimals)}m`;
|
|
12
|
+
}
|
|
13
|
+
return `${k.toFixed(decimals)}k`;
|
|
14
|
+
}
|
|
15
|
+
return `${Math.round(value)}`;
|
|
16
|
+
}
|
|
17
|
+
function formatCurrency(value, currency) {
|
|
18
|
+
const safe = Number.isFinite(value) ? value : 0;
|
|
19
|
+
const prefix = typeof currency === 'string' && currency ? currency : '$';
|
|
20
|
+
if (safe === 0)
|
|
21
|
+
return `${prefix}0.00`;
|
|
22
|
+
if (safe < 10 && safe > -10)
|
|
23
|
+
return `${safe < 0 ? '-' : ''}${prefix}${Math.abs(safe).toFixed(2)}`;
|
|
24
|
+
const rounded = Math.abs(safe).toFixed(1).replace(/\.0$/, '');
|
|
25
|
+
return `${safe < 0 ? '-' : ''}${prefix}${rounded}`;
|
|
26
|
+
}
|
|
27
|
+
function formatApiCost(value) {
|
|
28
|
+
return formatCurrency(value, '$');
|
|
29
|
+
}
|
|
30
|
+
function formatPercent(value, decimals = 1) {
|
|
31
|
+
const safe = Number.isFinite(value) && value >= 0 ? value : 0;
|
|
32
|
+
const pct = (safe * 100).toFixed(decimals);
|
|
33
|
+
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
34
|
+
}
|
|
35
|
+
function compactCountdown(iso) {
|
|
36
|
+
if (!iso)
|
|
37
|
+
return 'n/a';
|
|
38
|
+
const timestamp = Date.parse(iso);
|
|
39
|
+
if (Number.isNaN(timestamp))
|
|
40
|
+
return 'n/a';
|
|
41
|
+
const remainingMs = timestamp - Date.now();
|
|
42
|
+
if (!Number.isFinite(remainingMs))
|
|
43
|
+
return 'n/a';
|
|
44
|
+
if (remainingMs <= 0)
|
|
45
|
+
return '0m';
|
|
46
|
+
const totalMinutes = Math.max(1, Math.floor(remainingMs / 60_000));
|
|
47
|
+
if (totalMinutes < 60)
|
|
48
|
+
return `${totalMinutes}m`;
|
|
49
|
+
if (totalMinutes < 24 * 60) {
|
|
50
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
51
|
+
const minutes = totalMinutes % 60;
|
|
52
|
+
return `${hours}h${`${minutes}`.padStart(2, '0')}m`;
|
|
53
|
+
}
|
|
54
|
+
const days = Math.floor(totalMinutes / (24 * 60));
|
|
55
|
+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
|
56
|
+
return `${days}D${`${hours}`.padStart(2, '0')}h`;
|
|
57
|
+
}
|
|
58
|
+
function gauge(value, width = 10) {
|
|
59
|
+
if (value === undefined || !Number.isFinite(value))
|
|
60
|
+
return `${'░'.repeat(width)} n/a`;
|
|
61
|
+
const ratio = Math.max(0, Math.min(1, value / 100));
|
|
62
|
+
const filled = Math.max(value > 0 ? 1 : 0, Math.round(ratio * width));
|
|
63
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${`${Math.round(value)}`.padStart(3, ' ')}%`;
|
|
64
|
+
}
|
|
65
|
+
function formatDelta(current, previous, format) {
|
|
66
|
+
if (previous === undefined)
|
|
67
|
+
return `${format(current)} now`;
|
|
68
|
+
if (!Number.isFinite(previous) || previous < 0)
|
|
69
|
+
return `${format(current)} now`;
|
|
70
|
+
if (previous === 0)
|
|
71
|
+
return `${format(current)} now, ${current === 0 ? 'flat' : 'new'}`;
|
|
72
|
+
const delta = ((current - previous) / previous) * 100;
|
|
73
|
+
const rounded = Math.abs(delta) >= 10 ? delta.toFixed(0) : delta.toFixed(1);
|
|
74
|
+
const normalized = rounded.replace(/\.0$/, '');
|
|
75
|
+
return `${format(current)} now, ${delta > 0 ? '+' : ''}${normalized}%`;
|
|
76
|
+
}
|
|
77
|
+
function clip(value, width) {
|
|
78
|
+
return value.length <= width
|
|
79
|
+
? value
|
|
80
|
+
: `${value.slice(0, Math.max(0, width - 1))}~`;
|
|
81
|
+
}
|
|
82
|
+
function centerLine(value, width) {
|
|
83
|
+
const clipped = clip(value, width);
|
|
84
|
+
if (clipped.length >= width)
|
|
85
|
+
return clipped;
|
|
86
|
+
const left = Math.floor((width - clipped.length) / 2);
|
|
87
|
+
const right = width - clipped.length - left;
|
|
88
|
+
return `${' '.repeat(left)}${clipped}${' '.repeat(right)}`;
|
|
89
|
+
}
|
|
90
|
+
function padRight(value, width) {
|
|
91
|
+
return clip(value, width).padEnd(width, ' ');
|
|
92
|
+
}
|
|
93
|
+
function box(title, lines, width = 78) {
|
|
94
|
+
const longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
95
|
+
const inner = Math.max(48, width, title.length, longestLine);
|
|
96
|
+
const top = centerLine(title, inner);
|
|
97
|
+
const rule = '─'.repeat(inner);
|
|
98
|
+
const body = lines.map((line) => clip(line, inner));
|
|
99
|
+
return [top, rule, ...body, rule].join('\n');
|
|
100
|
+
}
|
|
101
|
+
function currentLabel(period) {
|
|
102
|
+
if (period === 'day')
|
|
103
|
+
return 'Today';
|
|
104
|
+
if (period === 'week')
|
|
105
|
+
return 'This Week';
|
|
106
|
+
return 'This Month';
|
|
107
|
+
}
|
|
108
|
+
function historyLabel(result) {
|
|
109
|
+
if (result.period === 'day')
|
|
110
|
+
return `Daily since ${result.since.raw}`;
|
|
111
|
+
if (result.period === 'week')
|
|
112
|
+
return `Weekly since ${result.since.raw}`;
|
|
113
|
+
return `Monthly since ${result.since.raw}`;
|
|
114
|
+
}
|
|
115
|
+
function quotaRows(quotas) {
|
|
116
|
+
const visible = collapseQuotaSnapshots(quotas).filter((item) => item.status === 'ok' || item.status === 'error');
|
|
117
|
+
if (visible.length === 0)
|
|
118
|
+
return ['no provider quota data available'];
|
|
119
|
+
return visible.flatMap((quota) => {
|
|
120
|
+
const label = quotaDisplayLabel(quota).padEnd(11, ' ');
|
|
121
|
+
if (quota.status === 'error') {
|
|
122
|
+
return [`${label} error${quota.note ? ` · ${quota.note}` : ''}`];
|
|
123
|
+
}
|
|
124
|
+
if (quota.windows && quota.windows.length > 0) {
|
|
125
|
+
const lines = quota.windows.map((win) => {
|
|
126
|
+
const detail = padRight(win.label || 'quota', 18);
|
|
127
|
+
if (win.showPercent === false) {
|
|
128
|
+
return `${label}${detail} ${compactCountdown(win.resetAt)}`;
|
|
129
|
+
}
|
|
130
|
+
return `${label}${detail} [${gauge(win.remainingPercent)}] ${compactCountdown(win.resetAt)}`;
|
|
131
|
+
});
|
|
132
|
+
if (quota.balance) {
|
|
133
|
+
lines.push(`${label}${padRight('balance', 18)} ${formatCurrency(quota.balance.amount, quota.balance.currency)}`);
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
if (quota.balance) {
|
|
138
|
+
return [
|
|
139
|
+
`${label}${padRight('balance', 18)} ${formatCurrency(quota.balance.amount, quota.balance.currency)}`,
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
return [
|
|
143
|
+
`${label}[${gauge(quota.remainingPercent)}] · ${compactCountdown(quota.resetAt)}`,
|
|
144
|
+
];
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function providerRows(usage, showCost) {
|
|
148
|
+
const providers = Object.values(usage.providers).sort((a, b) => b.total - a.total);
|
|
149
|
+
if (providers.length === 0)
|
|
150
|
+
return ['no provider activity'];
|
|
151
|
+
return providers.map((provider) => {
|
|
152
|
+
const cache = getProviderCacheCoverageMetrics(provider).cachedRatio;
|
|
153
|
+
const base = `${quotaDisplayLabel({ providerID: provider.providerID, label: provider.providerID, status: 'ok', checkedAt: 0 }).padEnd(10, ' ')} ${shortNumber(provider.assistantMessages).padStart(4, ' ')} req ${shortNumber(provider.total).padStart(7, ' ')} tok ${(cache !== undefined ? formatPercent(cache, 0) : '-').padStart(4, ' ')} cache`;
|
|
154
|
+
const apiCost = canonicalProviderID(provider.providerID) === 'github-copilot'
|
|
155
|
+
? '-'
|
|
156
|
+
: formatApiCost(provider.apiCost);
|
|
157
|
+
return showCost ? `${base} ${apiCost.padStart(7, ' ')}` : base;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function cliApiCostSummary(usage) {
|
|
161
|
+
const providers = Object.values(usage.providers);
|
|
162
|
+
if (providers.length === 0)
|
|
163
|
+
return formatApiCost(usage.apiCost);
|
|
164
|
+
const hasNonCopilot = providers.some((provider) => canonicalProviderID(provider.providerID) !== 'github-copilot');
|
|
165
|
+
return hasNonCopilot ? formatApiCost(usage.apiCost) : '-';
|
|
166
|
+
}
|
|
167
|
+
function totalsRows(input) {
|
|
168
|
+
const left = [`Requests ${input.requests}`, `Tokens ${input.tokens}`];
|
|
169
|
+
const right = [
|
|
170
|
+
...(input.cost ? [`Cost ${input.cost}`] : []),
|
|
171
|
+
...(input.cache ? [`Cache ${input.cache}`] : []),
|
|
172
|
+
];
|
|
173
|
+
const metaLeft = input.periods ? `Periods ${input.periods}` : undefined;
|
|
174
|
+
const metaRight = input.current ? `Current ${input.current}` : undefined;
|
|
175
|
+
const row1 = [left[0], left[1], ...right].join(' ');
|
|
176
|
+
const row2 = [metaLeft, metaRight].filter(Boolean).join(' ');
|
|
177
|
+
return [row1, ...(row2 ? [row2] : [])];
|
|
178
|
+
}
|
|
179
|
+
function trendBar(value, maxValue, width = 20) {
|
|
180
|
+
if (!Number.isFinite(value) || value <= 0 || maxValue <= 0) {
|
|
181
|
+
return '░'.repeat(width);
|
|
182
|
+
}
|
|
183
|
+
const filled = Math.max(1, Math.round((value / maxValue) * width));
|
|
184
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
|
|
185
|
+
}
|
|
186
|
+
function trendMetricBlock(input) {
|
|
187
|
+
const visibleRows = input.rows.slice(-Math.min(8, input.rows.length));
|
|
188
|
+
const values = visibleRows.map(input.pick);
|
|
189
|
+
const maxValue = Math.max(...values, 0);
|
|
190
|
+
const currentValue = input.current ? input.pick(input.current) : 0;
|
|
191
|
+
const displayLabels = visibleRows.map((row) => `${row.range.shortLabel}${row.range.isCurrent ? '*' : ''}`);
|
|
192
|
+
const labelWidth = Math.max(8, Math.min(28, Math.max(...displayLabels.map((label) => label.length), 8)));
|
|
193
|
+
return [
|
|
194
|
+
`${input.label} ${input.format(currentValue)}`,
|
|
195
|
+
...visibleRows.map((row, index) => {
|
|
196
|
+
const value = input.pick(row);
|
|
197
|
+
const tag = padRight(displayLabels[index], labelWidth);
|
|
198
|
+
return ` ${tag} | ${trendBar(value, maxValue)} | ${input.format(value)}`;
|
|
199
|
+
}),
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
export function renderCliDashboard(input) {
|
|
203
|
+
const width = input.width ?? 78;
|
|
204
|
+
const showCost = input.showCost !== false;
|
|
205
|
+
const cache = getCacheCoverageMetrics(input.usage).cachedRatio;
|
|
206
|
+
return box(`opencode-quota · ${input.label}`, [
|
|
207
|
+
'QUOTA',
|
|
208
|
+
...quotaRows(input.quotas),
|
|
209
|
+
'',
|
|
210
|
+
'TOTALS',
|
|
211
|
+
...totalsRows({
|
|
212
|
+
requests: shortNumber(input.usage.assistantMessages),
|
|
213
|
+
tokens: shortNumber(input.usage.total),
|
|
214
|
+
...(showCost ? { cost: cliApiCostSummary(input.usage) } : {}),
|
|
215
|
+
cache: cache !== undefined ? formatPercent(cache, 1) : '-',
|
|
216
|
+
periods: `${input.usage.sessionCount}`,
|
|
217
|
+
}),
|
|
218
|
+
`Input ${shortNumber(input.usage.input)} Output ${shortNumber(input.usage.output)}`,
|
|
219
|
+
'',
|
|
220
|
+
'PROVIDERS',
|
|
221
|
+
...providerRows(input.usage, showCost),
|
|
222
|
+
], width);
|
|
223
|
+
}
|
|
224
|
+
export function renderCliHistoryDashboard(input) {
|
|
225
|
+
const width = input.width ?? 78;
|
|
226
|
+
const showCost = input.showCost !== false;
|
|
227
|
+
const rows = input.result.rows;
|
|
228
|
+
const current = [...rows].reverse().find((row) => row.range.isCurrent) || rows.at(-1);
|
|
229
|
+
const currentIndex = current ? rows.indexOf(current) : -1;
|
|
230
|
+
const previous = currentIndex > 0 ? rows[currentIndex - 1] : undefined;
|
|
231
|
+
const cache = getCacheCoverageMetrics(input.result.total).cachedRatio;
|
|
232
|
+
const trendBlocks = [
|
|
233
|
+
...trendMetricBlock({
|
|
234
|
+
label: 'Requests',
|
|
235
|
+
rows,
|
|
236
|
+
current,
|
|
237
|
+
pick: (row) => row.usage.assistantMessages,
|
|
238
|
+
format: (value) => shortNumber(value),
|
|
239
|
+
}),
|
|
240
|
+
'',
|
|
241
|
+
...trendMetricBlock({
|
|
242
|
+
label: 'Tokens',
|
|
243
|
+
rows,
|
|
244
|
+
current,
|
|
245
|
+
pick: (row) => row.usage.total,
|
|
246
|
+
format: (value) => shortNumber(value),
|
|
247
|
+
}),
|
|
248
|
+
'',
|
|
249
|
+
...trendMetricBlock({
|
|
250
|
+
label: 'Cache',
|
|
251
|
+
rows,
|
|
252
|
+
current,
|
|
253
|
+
pick: (row) => getCacheCoverageMetrics(row.usage).cachedRatio ?? 0,
|
|
254
|
+
format: (value) => formatPercent(value, 1),
|
|
255
|
+
}),
|
|
256
|
+
...(showCost
|
|
257
|
+
? [
|
|
258
|
+
'',
|
|
259
|
+
...trendMetricBlock({
|
|
260
|
+
label: 'Cost',
|
|
261
|
+
rows,
|
|
262
|
+
current,
|
|
263
|
+
pick: (row) => row.usage.apiCost,
|
|
264
|
+
format: (value) => formatApiCost(value),
|
|
265
|
+
}),
|
|
266
|
+
]
|
|
267
|
+
: []),
|
|
268
|
+
];
|
|
269
|
+
return box(`opencode-quota · ${historyLabel(input.result)}`, [
|
|
270
|
+
'QUOTA',
|
|
271
|
+
...quotaRows(input.quotas),
|
|
272
|
+
'',
|
|
273
|
+
'TOTALS',
|
|
274
|
+
...totalsRows({
|
|
275
|
+
requests: shortNumber(input.result.total.assistantMessages),
|
|
276
|
+
tokens: shortNumber(input.result.total.total),
|
|
277
|
+
...(showCost ? { cost: cliApiCostSummary(input.result.total) } : {}),
|
|
278
|
+
cache: cache !== undefined ? formatPercent(cache, 1) : '-',
|
|
279
|
+
periods: `${rows.length}`,
|
|
280
|
+
current: current?.range.shortLabel || '-',
|
|
281
|
+
}),
|
|
282
|
+
'',
|
|
283
|
+
'PROVIDERS',
|
|
284
|
+
...providerRows(input.result.total, showCost),
|
|
285
|
+
'',
|
|
286
|
+
'TREND',
|
|
287
|
+
...trendBlocks,
|
|
288
|
+
], width);
|
|
289
|
+
}
|
|
290
|
+
export function cliCurrentLabel(period) {
|
|
291
|
+
return currentLabel(period);
|
|
292
|
+
}
|
package/dist/events.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare function createEventDispatcher(handlers: {
|
|
|
3
3
|
onSessionCreated: (session: Session) => Promise<void>;
|
|
4
4
|
onSessionUpdated: (session: Session) => Promise<void>;
|
|
5
5
|
onSessionDeleted: (session: Session) => Promise<void>;
|
|
6
|
-
onTuiActivity: () => Promise<void>;
|
|
6
|
+
onTuiActivity: (sessionID?: string) => Promise<void>;
|
|
7
7
|
onTuiSessionSelect: (sessionID: string) => Promise<void>;
|
|
8
8
|
onMessageRemoved: (info: {
|
|
9
9
|
sessionID: string;
|
package/dist/events.js
CHANGED
|
@@ -18,14 +18,14 @@ export function createEventDispatcher(handlers) {
|
|
|
18
18
|
}
|
|
19
19
|
if (tui.type === 'tui.prompt.append' ||
|
|
20
20
|
tui.type === 'tui.command.execute') {
|
|
21
|
-
await handlers.onTuiActivity();
|
|
21
|
+
await handlers.onTuiActivity(tui.properties?.sessionID);
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
if (tui.type === 'tui.session.select') {
|
|
25
25
|
if (typeof tui.properties?.sessionID !== 'string')
|
|
26
26
|
return;
|
|
27
27
|
await handlers.onTuiSessionSelect(tui.properties.sessionID);
|
|
28
|
-
await handlers.onTuiActivity();
|
|
28
|
+
await handlers.onTuiActivity(tui.properties.sessionID);
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
if (event.type === 'message.removed') {
|