@leo000001/opencode-quota-sidebar 2.0.23 → 3.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 +107 -34
- package/dist/format.d.ts +10 -6
- package/dist/format.js +102 -55
- package/dist/index.js +12 -56
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +12 -0
- package/dist/storage_chunks.js +21 -7
- package/dist/storage_parse.d.ts +2 -2
- package/dist/storage_parse.js +94 -63
- package/dist/title_apply.js +24 -4
- package/dist/tui.d.ts +6 -0
- package/dist/tui.tsx +363 -0
- package/dist/types.d.ts +10 -2
- package/package.json +21 -7
package/README.md
CHANGED
|
@@ -3,21 +3,35 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@leo000001/opencode-quota-sidebar)
|
|
4
4
|
[](https://github.com/xihuai18/opencode-quota-sidebar/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
OpenCode plugin: show token usage and subscription quota in
|
|
6
|
+
OpenCode plugin: show token usage and subscription quota in TUI sidebar panels and compact shared session titles.
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The package manifest advertises both `server` and `tui` targets, but OpenCode loads those targets from different config files at runtime.
|
|
13
|
+
|
|
14
|
+
If you configure the plugin manually, you must add the server entry to `opencode.json` and the TUI entry to `tui.json`:
|
|
15
|
+
|
|
16
|
+
`opencode.json`
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.26"]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`tui.json`
|
|
13
25
|
|
|
14
26
|
```json
|
|
15
27
|
{
|
|
16
|
-
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.
|
|
28
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.26"]
|
|
17
29
|
}
|
|
18
30
|
```
|
|
19
31
|
|
|
20
|
-
Note for OpenCode `>=1.2.15`: TUI settings
|
|
32
|
+
Note for OpenCode `>=1.2.15`: TUI settings and TUI plugins live in `tui.json`, while server plugins stay in `opencode.json`.
|
|
33
|
+
|
|
34
|
+
If you use an installer flow that reads `oc-plugin` targets and patches config for you, it can populate both files automatically. Simply having the package installed in `node_modules` or listed only in `opencode.json` is not enough for the TUI runtime to load `./tui`.
|
|
21
35
|
This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
|
|
22
36
|
|
|
23
37
|
If you prefer automatic upgrades, you can still use `@latest`, but pinning an exact version makes behavior easier to reproduce when debugging.
|
|
@@ -29,7 +43,9 @@ npm install
|
|
|
29
43
|
npm run build
|
|
30
44
|
```
|
|
31
45
|
|
|
32
|
-
Add the built file to your `opencode.json`:
|
|
46
|
+
Add the built server file to your `opencode.json` and the TUI file to your `tui.json`:
|
|
47
|
+
|
|
48
|
+
`opencode.json`
|
|
33
49
|
|
|
34
50
|
```json
|
|
35
51
|
{
|
|
@@ -37,7 +53,18 @@ Add the built file to your `opencode.json`:
|
|
|
37
53
|
}
|
|
38
54
|
```
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
`tui.json`
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"plugin": ["file:///ABSOLUTE/PATH/opencode-quota-sidebar/dist/tui.tsx"]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
On Windows, use forward slashes, for example:
|
|
65
|
+
|
|
66
|
+
- `file:///D:/Lab/opencode-quota-sidebar/dist/index.js`
|
|
67
|
+
- `file:///D:/Lab/opencode-quota-sidebar/dist/tui.tsx`
|
|
41
68
|
|
|
42
69
|
## Supported quota providers
|
|
43
70
|
|
|
@@ -55,21 +82,16 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
55
82
|
|
|
56
83
|
## Features
|
|
57
84
|
|
|
58
|
-
- TUI
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
- long quota content wraps across extra compact lines instead of dropping fields from the sidebar, and continuation lines align to the quota content column
|
|
67
|
-
- Desktop automatically switches to a compact monitoring-style single-line title. It keeps recently used providers from the last `50` requests or last `60` minutes, expands all windows/balance for those selected providers in short form such as `OAI 5h80 R16:20 W70 R04-03` or `RC D88.9/60 B260`, and keeps only summary usage signals such as `Cd66%` and `Est$0.12`
|
|
68
|
-
- Auto mode now prefers compact single-line titles everywhere except the actively selected TUI session. That means Desktop stays compact, Web UI / `serve` clients also use compact single-line titles, and the current TUI session keeps the compact multiline layout.
|
|
85
|
+
- TUI sidebar can render a dedicated block layout instead of stuffing telemetry into the shared title:
|
|
86
|
+
- `TITLE`: clean base session title
|
|
87
|
+
- `USAGE`: compact request/input/output/cache lines such as `R184 I189k O53.2k`, `CR31.4k CW3.2k Cd66%`, `Est $12.8`
|
|
88
|
+
- `QUOTA`: one compact provider group per provider, with indented continuation lines for multi-window quotas or balances
|
|
89
|
+
- while active, the TUI plugin temporarily deactivates the built-in `internal:sidebar-context` block so the custom panel does not duplicate it
|
|
90
|
+
- Shared `session.title` now stays compact in `auto` mode. Desktop, Web UI / `serve`, and TUI all keep the shared title on a compact single line such as `<base> | OAI 5h80 R16:20 W70 R04-03 | RC D88.9/60 B260 | Cd66% | Est$0.12`.
|
|
91
|
+
- TUI panel data is read from persisted day-chunk session state (`usage` + `sidebarPanel`) so entering or resuming a session can render from persistence first; compact-title parsing remains only a defensive fallback.
|
|
92
|
+
- `sidebar.titleMode=multiline` is still available as a legacy fallback when you explicitly want the old multiline title decoration path.
|
|
69
93
|
- `sidebar.titleMode` can force `auto`, `multiline`, or `compact` if the heuristic does not match your workflow.
|
|
70
|
-
-
|
|
71
|
-
- Auto-mode freshness lasts `15` minutes. If no new TUI activity arrives in that window, the tracked TUI session is refreshed back to compact; the next TUI activity re-enables multiline for the last selected TUI session.
|
|
72
|
-
- Multi-client caveat: the plugin tracks one global TUI-selected session and writes one shared `session.title`. In mixed TUI/Web or multi-TUI setups, the latest TUI selection wins for that session title. Use `sidebar.titleMode="compact"` or `"multiline"` if you need a stable forced policy.
|
|
94
|
+
- Multi-client caveat: the shared title is still one `session.title` for every client. The new TUI sidebar blocks avoid polluting that shared title, but Desktop/Web still see the compact shared title rather than a sidebar panel.
|
|
73
95
|
- Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
|
|
74
96
|
- Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cached ratios are available), and `Quota`
|
|
75
97
|
- Expiry reminders are shown in a separate `Expiry Soon` toast section only for providers with real subscription expiry timestamps, and each session shows that auto-reminder at most once
|
|
@@ -77,7 +99,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
77
99
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
78
100
|
- Custom tools:
|
|
79
101
|
- `quota_summary` — generate usage report for session/day/week/month (full markdown report + toast). The markdown report and toast keep the full human-readable wording; they do not switch to compact sidebar tokens.
|
|
80
|
-
- `quota_show` — toggle
|
|
102
|
+
- `quota_show` — toggle shared title decoration on/off (state persists across sessions)
|
|
81
103
|
- After startup, titles are restored immediately when persisted display mode is OFF; when persisted display mode is ON, touched titles refresh on startup and the rest update on the next relevant session/message event or when `quota_show` is toggled
|
|
82
104
|
- Quota connectors:
|
|
83
105
|
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
@@ -122,6 +144,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
|
|
|
122
144
|
- `parentID` (when the session is a subagent child session)
|
|
123
145
|
- `expiryToastShown` (session-level dedupe for automatic expiry reminders)
|
|
124
146
|
- cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cached-ratio reporting and legacy cache classification
|
|
147
|
+
- `sidebarPanel` cache used by the TUI sidebar plugin (`version`, cached usage, compact quota snapshots`) so `TITLE / USAGE / QUOTA` can render from persisted structure on session open/resume
|
|
125
148
|
- incremental aggregation cursor
|
|
126
149
|
|
|
127
150
|
Notes on cache bucket persistence:
|
|
@@ -147,7 +170,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
|
|
|
147
170
|
## Compatibility
|
|
148
171
|
|
|
149
172
|
- Node.js: >= 18 (for `fetch` + `AbortController`)
|
|
150
|
-
- OpenCode: plugin SDK `@opencode-ai/plugin` ^1.
|
|
173
|
+
- OpenCode: plugin SDK `@opencode-ai/plugin` ^1.3.5
|
|
151
174
|
- OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
|
|
152
175
|
|
|
153
176
|
## Force refresh after npm update
|
|
@@ -156,7 +179,7 @@ If `npm view @leo000001/opencode-quota-sidebar version` shows a newer version bu
|
|
|
156
179
|
|
|
157
180
|
Recommended recovery steps:
|
|
158
181
|
|
|
159
|
-
1. Pin the target plugin version in `opencode.json
|
|
182
|
+
1. Pin the target plugin version in every config file that loads it (`opencode.json` and, if used manually, `tui.json`).
|
|
160
183
|
2. Fully exit OpenCode.
|
|
161
184
|
3. Delete any cached installed copies of the plugin.
|
|
162
185
|
4. Start OpenCode again so it reinstalls the package.
|
|
@@ -323,15 +346,48 @@ Other defaults:
|
|
|
323
346
|
}
|
|
324
347
|
```
|
|
325
348
|
|
|
349
|
+
### Common minimal configs
|
|
350
|
+
|
|
351
|
+
Keep the shared title compact and let TUI render the structured panel:
|
|
352
|
+
|
|
353
|
+
```json
|
|
354
|
+
{
|
|
355
|
+
"sidebar": {
|
|
356
|
+
"enabled": true,
|
|
357
|
+
"titleMode": "auto",
|
|
358
|
+
"showCost": true,
|
|
359
|
+
"showQuota": true,
|
|
360
|
+
"includeChildren": true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Enable XYAI Vibe quota with account login:
|
|
366
|
+
|
|
367
|
+
```json
|
|
368
|
+
{
|
|
369
|
+
"quota": {
|
|
370
|
+
"providers": {
|
|
371
|
+
"xyai-vibe": {
|
|
372
|
+
"enabled": true,
|
|
373
|
+
"login": {
|
|
374
|
+
"username": "your-account",
|
|
375
|
+
"password": "your-password"
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
326
383
|
### Notes
|
|
327
384
|
|
|
328
|
-
- `sidebar.showCost` controls API-cost visibility in
|
|
385
|
+
- `sidebar.showCost` controls API-cost visibility in the TUI `USAGE` block, the compact shared title, `quota_summary` markdown report, and toast message.
|
|
329
386
|
- `quota_summary` follows the same reset compaction rules for short windows in its subscription section (`5h` / `1d` / `Daily` show time, long windows show date, RightCode `Exp` stays date-only).
|
|
330
387
|
- `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
|
|
331
|
-
- `sidebar.titleMode` defaults to `auto`:
|
|
332
|
-
-
|
|
333
|
-
- The
|
|
334
|
-
- The plugin tracks one global `tuiSessionID` and one shared session title per plugin process, not one title per window/tab/client. In multi-client or shared-server setups, different TUI/Web viewers can influence each other. If you need predictable rendering, force `sidebar.titleMode`.
|
|
388
|
+
- `sidebar.titleMode` defaults to `auto`: the shared `session.title` stays compact for Desktop, Web UI / `serve`, and TUI alike. The rich TUI layout comes from the dedicated TUI plugin slots instead of a multiline shared title. Use `multiline` only if you explicitly want the legacy decorated-title path, or `compact` to force compact titles everywhere.
|
|
389
|
+
- The TUI plugin renders `TITLE`, `USAGE`, and `QUOTA` blocks in the sidebar and temporarily disables the built-in `internal:sidebar-context` block while it is active.
|
|
390
|
+
- The shared `session.title` is still one string per session for all clients. TUI sidebar blocks avoid polluting that title, but Desktop/Web still see the compact shared title rather than a TUI panel.
|
|
335
391
|
- `sidebar.multilineTitle` is kept for backward compatibility, but `sidebar.titleMode` now controls the active policy.
|
|
336
392
|
- `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
|
|
337
393
|
- `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
|
|
@@ -339,7 +395,7 @@ Other defaults:
|
|
|
339
395
|
- `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
|
|
340
396
|
- `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
|
|
341
397
|
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` control which recently used providers remain visible in compact single-line titles.
|
|
342
|
-
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` only control provider filtering inside compact titles; they do not
|
|
398
|
+
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` only control provider filtering inside compact titles; they do not affect the dedicated TUI sidebar panel.
|
|
343
399
|
- `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
|
|
344
400
|
- API cost bills reasoning tokens at the output rate (same as completion tokens).
|
|
345
401
|
- API cost is computed from OpenCode model pricing metadata, not from `message.cost`. This keeps subscription-backed providers such as OpenAI OAuth usable for API-equivalent cost estimation even when OpenCode's measured cost is `0`.
|
|
@@ -378,7 +434,24 @@ These examples show the quota block portion of the sidebar title.
|
|
|
378
434
|
|
|
379
435
|
### TUI layout
|
|
380
436
|
|
|
381
|
-
|
|
437
|
+
The default TUI layout is a real sidebar panel, not a multiline shared title. Desktop and Web UI / `serve` clients keep the compact shared title unless you force `sidebar.titleMode = "multiline"`.
|
|
438
|
+
|
|
439
|
+
Typical layout:
|
|
440
|
+
|
|
441
|
+
```text
|
|
442
|
+
TITLE
|
|
443
|
+
Fix quota adapter matching
|
|
444
|
+
USAGE
|
|
445
|
+
R184 I189k O53.2k
|
|
446
|
+
CR31.4k CW3.2k Cd66%
|
|
447
|
+
Est $12.8
|
|
448
|
+
QUOTA
|
|
449
|
+
OAI 5h80 R16:20
|
|
450
|
+
W70 R04-03
|
|
451
|
+
Cop M78 R04-01
|
|
452
|
+
RC D$88.9/$60 E02-27
|
|
453
|
+
B260
|
|
454
|
+
```
|
|
382
455
|
|
|
383
456
|
### Force modes
|
|
384
457
|
|
|
@@ -479,9 +552,9 @@ Cop unavailable
|
|
|
479
552
|
OAI ?
|
|
480
553
|
```
|
|
481
554
|
|
|
482
|
-
###
|
|
555
|
+
### Compact shared title mode
|
|
483
556
|
|
|
484
|
-
Desktop
|
|
557
|
+
Desktop and Web UI / `serve` use a compact monitoring-style single-line shared title in `auto` mode. Recently used providers are selected from the last `50` assistant requests or last `60` minutes, and each selected provider expands all of its windows and balances in shorthand. To survive upstream truncation better, quota segments are emitted before usage summary signals:
|
|
485
558
|
|
|
486
559
|
```text
|
|
487
560
|
<base> | OAI 5h80 R16:20 W70 R04-03 | Cop M78 R04-01 | RC D88.9/60 B260 | Buzz B¥10.2 | Cd66% | Est$0.12
|
|
@@ -496,8 +569,8 @@ Shorthand rules:
|
|
|
496
569
|
- `B260` / `B¥10.2` = balance
|
|
497
570
|
- `Cd66%` = cached ratio (`cache.read / (input + cache.read)`)
|
|
498
571
|
- `Est$0.12` = equivalent API cost estimate
|
|
499
|
-
-
|
|
500
|
-
- Order is `base | quota... | usage-summary
|
|
572
|
+
- Compact shared titles omit `R/I/O/CR/CW`; the dedicated TUI sidebar keeps the richer `TITLE / USAGE / QUOTA` breakdown.
|
|
573
|
+
- Order is `base | quota... | usage-summary` for compact shared titles.
|
|
501
574
|
|
|
502
575
|
`quota_summary` also supports an optional `includeChildren` flag (only effective for `period=session`) to override the config per call. For `day`/`week`/`month` periods, children are never merged — each session is counted independently.
|
|
503
576
|
|
package/dist/format.d.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
|
|
2
2
|
import { type UsageSummary } from './usage.js';
|
|
3
3
|
export type TitleView = 'multiline' | 'compact';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Truncate `value` to at most `width` terminal cells.
|
|
6
|
+
* Keep plain text only (no ANSI) to avoid renderer corruption.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fitLine(value: string, width: number): string;
|
|
6
9
|
export declare function resolveTitleView(opts: {
|
|
7
10
|
config: QuotaSidebarConfig;
|
|
8
|
-
sessionID?: string;
|
|
9
|
-
tuiSessionID?: string;
|
|
10
|
-
tuiActiveAt?: number;
|
|
11
|
-
now?: number;
|
|
12
11
|
}): TitleView;
|
|
13
12
|
export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, config: QuotaSidebarConfig, now?: number): string[];
|
|
14
13
|
/**
|
|
@@ -23,6 +22,11 @@ export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, con
|
|
|
23
22
|
* OpenAI Remaining 78% (only if quota available)
|
|
24
23
|
*/
|
|
25
24
|
export declare function renderSidebarTitle(baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig, view?: TitleView): string;
|
|
25
|
+
export declare function renderSidebarContextLine(tokens: number, percent: number | undefined, width: number): string;
|
|
26
|
+
export declare function renderSidebarUsageLines(usage: UsageSummary, config: QuotaSidebarConfig, options?: {
|
|
27
|
+
showCost?: boolean;
|
|
28
|
+
}): string[];
|
|
29
|
+
export declare function renderSidebarQuotaLines(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): string[];
|
|
26
30
|
export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
|
|
27
31
|
showCost?: boolean;
|
|
28
32
|
}): string;
|
package/dist/format.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getCacheCoverageMetrics, getProviderCacheCoverageMetrics, } from './usage.js';
|
|
2
2
|
import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
|
|
3
3
|
import { stripAnsi } from './title.js';
|
|
4
|
-
export const TUI_ACTIVE_MS = 15 * 60_000;
|
|
5
4
|
/** M6 fix: handle negative, NaN, Infinity gracefully. */
|
|
6
5
|
function shortNumber(value, decimals = 1) {
|
|
7
6
|
if (!Number.isFinite(value) || value < 0)
|
|
@@ -114,7 +113,7 @@ function truncateToCellWidth(value, width) {
|
|
|
114
113
|
* Truncate `value` to at most `width` terminal cells.
|
|
115
114
|
* Keep plain text only (no ANSI) to avoid renderer corruption.
|
|
116
115
|
*/
|
|
117
|
-
function fitLine(value, width) {
|
|
116
|
+
export function fitLine(value, width) {
|
|
118
117
|
if (width <= 0)
|
|
119
118
|
return '';
|
|
120
119
|
const safe = sanitizeLine(value);
|
|
@@ -157,25 +156,24 @@ function formatApiCostValue(value) {
|
|
|
157
156
|
function formatApiCostLine(value) {
|
|
158
157
|
return `${formatApiCostValue(value)} as API cost`;
|
|
159
158
|
}
|
|
159
|
+
function trimTrailingZeroUnit(value) {
|
|
160
|
+
return value
|
|
161
|
+
.replace(/(\d+)\.0(?=[km]\b)/i, '$1')
|
|
162
|
+
.replace(/(\d+)\.0(?=$)/, '$1');
|
|
163
|
+
}
|
|
164
|
+
function panelNumber(value) {
|
|
165
|
+
return trimTrailingZeroUnit(shortNumber(value, 1));
|
|
166
|
+
}
|
|
160
167
|
function formatRequestsLabel(value, short = false) {
|
|
161
168
|
const count = shortNumber(value, 1);
|
|
162
169
|
return short ? `Req ${count}` : `Requests ${count}`;
|
|
163
170
|
}
|
|
164
|
-
export function isDesktopClient() {
|
|
165
|
-
return process.env.OPENCODE_CLIENT === 'desktop';
|
|
166
|
-
}
|
|
167
171
|
export function resolveTitleView(opts) {
|
|
172
|
+
void opts;
|
|
168
173
|
if (opts.config.sidebar.titleMode === 'compact')
|
|
169
174
|
return 'compact';
|
|
170
175
|
if (opts.config.sidebar.titleMode === 'multiline')
|
|
171
176
|
return 'multiline';
|
|
172
|
-
if (isDesktopClient())
|
|
173
|
-
return 'compact';
|
|
174
|
-
if (opts.sessionID &&
|
|
175
|
-
opts.sessionID === opts.tuiSessionID &&
|
|
176
|
-
(opts.now ?? Date.now()) - (opts.tuiActiveAt ?? 0) <= TUI_ACTIVE_MS) {
|
|
177
|
-
return 'multiline';
|
|
178
|
-
}
|
|
179
177
|
return 'compact';
|
|
180
178
|
}
|
|
181
179
|
function desktopCompactSettings(config) {
|
|
@@ -378,6 +376,63 @@ function formatPercent(value, decimals = 1) {
|
|
|
378
376
|
const pct = (safe * 100).toFixed(decimals);
|
|
379
377
|
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
380
378
|
}
|
|
379
|
+
function fitsLine(value, width) {
|
|
380
|
+
return stringCellWidth(sanitizeLine(value)) <= width;
|
|
381
|
+
}
|
|
382
|
+
function usageDetailLines(usage, cacheMetrics, options) {
|
|
383
|
+
const width = options.width;
|
|
384
|
+
const numberToken = options.numberToken || sidebarNumber;
|
|
385
|
+
const costToken = options.costToken || ((value) => `Est${formatApiCostValue(value)}`);
|
|
386
|
+
const groups = [];
|
|
387
|
+
groups.push([
|
|
388
|
+
`R${shortNumber(usage.assistantMessages, 1)}`,
|
|
389
|
+
`I${numberToken(usage.input)}`,
|
|
390
|
+
`O${numberToken(usage.output)}`,
|
|
391
|
+
]);
|
|
392
|
+
const secondary = [];
|
|
393
|
+
const pushCacheRead = () => {
|
|
394
|
+
if (usage.cacheRead > 0) {
|
|
395
|
+
secondary.push(`CR${numberToken(usage.cacheRead)}`);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const pushCacheWrite = () => {
|
|
399
|
+
if (usage.cacheWrite > 0) {
|
|
400
|
+
secondary.push(`CW${numberToken(usage.cacheWrite)}`);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (options.cacheReadFirst) {
|
|
404
|
+
pushCacheRead();
|
|
405
|
+
pushCacheWrite();
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
pushCacheWrite();
|
|
409
|
+
pushCacheRead();
|
|
410
|
+
}
|
|
411
|
+
if (cacheMetrics.cachedRatio !== undefined) {
|
|
412
|
+
secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
|
|
413
|
+
}
|
|
414
|
+
if (secondary.length > 0)
|
|
415
|
+
groups.push(secondary);
|
|
416
|
+
if (options.showCost && usage.apiCost > 0) {
|
|
417
|
+
groups.push([costToken(usage.apiCost)]);
|
|
418
|
+
}
|
|
419
|
+
const packed = [];
|
|
420
|
+
for (const group of groups) {
|
|
421
|
+
let current = '';
|
|
422
|
+
for (const token of group) {
|
|
423
|
+
const candidate = current ? `${current} ${token}` : token;
|
|
424
|
+
if (!current || fitsLine(candidate, width)) {
|
|
425
|
+
current = candidate;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
packed.push(current);
|
|
429
|
+
current = token;
|
|
430
|
+
}
|
|
431
|
+
if (current)
|
|
432
|
+
packed.push(current);
|
|
433
|
+
}
|
|
434
|
+
return packed;
|
|
435
|
+
}
|
|
381
436
|
function formatQuotaPercent(value, options) {
|
|
382
437
|
const missing = options?.missing ?? '-';
|
|
383
438
|
if (value === undefined)
|
|
@@ -523,51 +578,43 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config, view) {
|
|
|
523
578
|
lines.push(fitLine(line, width));
|
|
524
579
|
}
|
|
525
580
|
}
|
|
526
|
-
function fitsLine(value, width) {
|
|
527
|
-
return stringCellWidth(sanitizeLine(value)) <= width;
|
|
528
|
-
}
|
|
529
|
-
function usageDetailLines(usage, cacheMetrics, options) {
|
|
530
|
-
const width = options.width;
|
|
531
|
-
const groups = [];
|
|
532
|
-
groups.push([
|
|
533
|
-
`R${shortNumber(usage.assistantMessages, 1)}`,
|
|
534
|
-
`I${sidebarNumber(usage.input)}`,
|
|
535
|
-
`O${sidebarNumber(usage.output)}`,
|
|
536
|
-
]);
|
|
537
|
-
const secondary = [];
|
|
538
|
-
if (usage.cacheWrite > 0) {
|
|
539
|
-
secondary.push(`CW${sidebarNumber(usage.cacheWrite)}`);
|
|
540
|
-
}
|
|
541
|
-
if (usage.cacheRead > 0) {
|
|
542
|
-
secondary.push(`CR${sidebarNumber(usage.cacheRead)}`);
|
|
543
|
-
}
|
|
544
|
-
if (cacheMetrics.cachedRatio !== undefined) {
|
|
545
|
-
secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
|
|
546
|
-
}
|
|
547
|
-
if (secondary.length > 0)
|
|
548
|
-
groups.push(secondary);
|
|
549
|
-
if (options.showCost && usage.apiCost > 0) {
|
|
550
|
-
groups.push([`Est${formatApiCostValue(usage.apiCost)}`]);
|
|
551
|
-
}
|
|
552
|
-
const packed = [];
|
|
553
|
-
for (const group of groups) {
|
|
554
|
-
let current = '';
|
|
555
|
-
for (const token of group) {
|
|
556
|
-
const candidate = current ? `${current} ${token}` : token;
|
|
557
|
-
if (!current || fitsLine(candidate, width)) {
|
|
558
|
-
current = candidate;
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
packed.push(current);
|
|
562
|
-
current = token;
|
|
563
|
-
}
|
|
564
|
-
if (current)
|
|
565
|
-
packed.push(current);
|
|
566
|
-
}
|
|
567
|
-
return packed;
|
|
568
|
-
}
|
|
569
581
|
return lines.join('\n');
|
|
570
582
|
}
|
|
583
|
+
export function renderSidebarContextLine(tokens, percent, width) {
|
|
584
|
+
const parts = [`${panelNumber(tokens)} tok`];
|
|
585
|
+
if (percent !== undefined && Number.isFinite(percent) && percent >= 0) {
|
|
586
|
+
parts.push(`${Math.round(percent)}% ctx`);
|
|
587
|
+
}
|
|
588
|
+
return fitLine(parts.join(' '), width);
|
|
589
|
+
}
|
|
590
|
+
export function renderSidebarUsageLines(usage, config, options) {
|
|
591
|
+
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
592
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
593
|
+
return usageDetailLines(usage, cacheMetrics, {
|
|
594
|
+
width,
|
|
595
|
+
showCost: options?.showCost ?? config.sidebar.showCost,
|
|
596
|
+
numberToken: panelNumber,
|
|
597
|
+
costToken: (value) => `Est ${formatApiCostValue(value)}`,
|
|
598
|
+
cacheReadFirst: true,
|
|
599
|
+
}).map((line) => fitLine(line, width));
|
|
600
|
+
}
|
|
601
|
+
export function renderSidebarQuotaLines(quotas, config) {
|
|
602
|
+
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
603
|
+
const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
|
|
604
|
+
const labelWidth = visibleQuotas.reduce((max, item) => {
|
|
605
|
+
const label = compactProviderLabel(item);
|
|
606
|
+
return Math.max(max, stringCellWidth(label));
|
|
607
|
+
}, 0);
|
|
608
|
+
return visibleQuotas
|
|
609
|
+
.flatMap((item) => compactQuotaWide(item, labelWidth, {
|
|
610
|
+
width,
|
|
611
|
+
wrapLines: config.sidebar.wrapQuotaLines,
|
|
612
|
+
forceWrapped: false,
|
|
613
|
+
compactDetails: true,
|
|
614
|
+
}))
|
|
615
|
+
.filter((line) => Boolean(line))
|
|
616
|
+
.map((line) => fitLine(line, width));
|
|
617
|
+
}
|
|
571
618
|
/**
|
|
572
619
|
* Multi-window quota format for sidebar.
|
|
573
620
|
*
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, TUI_ACTIVE_MS, } from './format.js';
|
|
1
|
+
import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
|
|
3
2
|
import { createQuotaRuntime } from './quota.js';
|
|
4
|
-
import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs,
|
|
3
|
+
import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
|
|
5
4
|
import { debug, swallow } from './helpers.js';
|
|
6
5
|
import { normalizeBaseTitle } from './title.js';
|
|
7
6
|
import { createDescendantsResolver } from './descendants.js';
|
|
@@ -16,16 +15,7 @@ const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
|
|
|
16
15
|
const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
|
|
17
16
|
export async function QuotaSidebarPlugin(input) {
|
|
18
17
|
const quotaRuntime = createQuotaRuntime();
|
|
19
|
-
const
|
|
20
|
-
const configOverride = process.env.OPENCODE_QUOTA_CONFIG?.trim();
|
|
21
|
-
const config = await loadConfig([
|
|
22
|
-
path.join(configDir, 'quota-sidebar.config.json'),
|
|
23
|
-
path.join(input.worktree, 'quota-sidebar.config.json'),
|
|
24
|
-
path.join(input.directory, 'quota-sidebar.config.json'),
|
|
25
|
-
path.join(input.worktree, '.opencode', 'quota-sidebar.config.json'),
|
|
26
|
-
path.join(input.directory, '.opencode', 'quota-sidebar.config.json'),
|
|
27
|
-
...(configOverride ? [path.resolve(configOverride)] : []),
|
|
28
|
-
]);
|
|
18
|
+
const config = await loadConfig(quotaConfigPaths(input.worktree, input.directory));
|
|
29
19
|
const dataDir = resolveOpencodeDataDir();
|
|
30
20
|
const statePath = stateFilePath(dataDir);
|
|
31
21
|
const authPath = authFilePath(dataDir);
|
|
@@ -126,23 +116,6 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
126
116
|
});
|
|
127
117
|
const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
|
|
128
118
|
const summarizeForTool = usageService.summarizeForTool;
|
|
129
|
-
let tuiSessionID;
|
|
130
|
-
let tuiActiveAt = 0;
|
|
131
|
-
let tuiTimer;
|
|
132
|
-
const armTuiTimer = () => {
|
|
133
|
-
if (tuiTimer)
|
|
134
|
-
clearTimeout(tuiTimer);
|
|
135
|
-
if (!tuiSessionID)
|
|
136
|
-
return;
|
|
137
|
-
tuiTimer = setTimeout(() => {
|
|
138
|
-
const id = tuiSessionID;
|
|
139
|
-
tuiActiveAt = 0;
|
|
140
|
-
tuiTimer = undefined;
|
|
141
|
-
if (id)
|
|
142
|
-
scheduleTitleRefresh(id, 0);
|
|
143
|
-
}, TUI_ACTIVE_MS);
|
|
144
|
-
tuiTimer.unref?.();
|
|
145
|
-
};
|
|
146
119
|
// title apply / refresh lifecycle
|
|
147
120
|
let scheduleTitleRefresh = (sessionID, delay = 250) => {
|
|
148
121
|
void sessionID;
|
|
@@ -178,7 +151,7 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
178
151
|
markDirty,
|
|
179
152
|
scheduleSave,
|
|
180
153
|
renderSidebarTitle,
|
|
181
|
-
getTitleView: (
|
|
154
|
+
getTitleView: () => resolveTitleView({ config }),
|
|
182
155
|
getQuotaSnapshots,
|
|
183
156
|
summarizeSessionUsageForDisplay,
|
|
184
157
|
scheduleParentRefreshIfSafe,
|
|
@@ -218,17 +191,18 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
218
191
|
.catch(swallow('startup:refreshAllTouchedTitles'));
|
|
219
192
|
}
|
|
220
193
|
const shutdown = async () => {
|
|
221
|
-
if (tuiTimer)
|
|
222
|
-
clearTimeout(tuiTimer);
|
|
223
194
|
await Promise.race([
|
|
224
195
|
startupTitleWork,
|
|
225
196
|
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
226
197
|
]).catch(swallow('shutdown:startupTitleWork'));
|
|
227
|
-
await titleRefresh
|
|
198
|
+
await titleRefresh
|
|
199
|
+
.waitForQuiescence()
|
|
200
|
+
.catch(swallow('shutdown:titleQuiescence'));
|
|
228
201
|
await flushSave().catch(swallow('shutdown:flushSave'));
|
|
229
202
|
};
|
|
230
203
|
const processWithHook = process;
|
|
231
|
-
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
|
|
204
|
+
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
|
|
205
|
+
new Set());
|
|
232
206
|
shutdownCallbacks.add(shutdown);
|
|
233
207
|
if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
|
|
234
208
|
processWithHook[SHUTDOWN_HOOK_KEY] = true;
|
|
@@ -300,7 +274,8 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
300
274
|
if (expiryLines.length === 0)
|
|
301
275
|
return;
|
|
302
276
|
sessionState.expiryToastShown = true;
|
|
303
|
-
const dateKey = state.sessionDateMap[sessionID] ||
|
|
277
|
+
const dateKey = state.sessionDateMap[sessionID] ||
|
|
278
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
304
279
|
state.sessionDateMap[sessionID] = dateKey;
|
|
305
280
|
markDirty(dateKey);
|
|
306
281
|
scheduleSave();
|
|
@@ -347,14 +322,6 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
347
322
|
},
|
|
348
323
|
onSessionDeleted: async (session) => {
|
|
349
324
|
await flushSave().catch(swallow('onSessionDeleted:flushSave'));
|
|
350
|
-
if (tuiSessionID === session.id) {
|
|
351
|
-
tuiSessionID = undefined;
|
|
352
|
-
tuiActiveAt = 0;
|
|
353
|
-
if (tuiTimer) {
|
|
354
|
-
clearTimeout(tuiTimer);
|
|
355
|
-
tuiTimer = undefined;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
325
|
descendantsResolver.invalidateForAncestors(session.parentID);
|
|
359
326
|
descendantsResolver.invalidateForAncestors(session.id);
|
|
360
327
|
usageService.forgetSession(session.id);
|
|
@@ -377,20 +344,9 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
377
344
|
}
|
|
378
345
|
},
|
|
379
346
|
onTuiActivity: async () => {
|
|
380
|
-
|
|
381
|
-
tuiActiveAt = Date.now();
|
|
382
|
-
armTuiTimer();
|
|
383
|
-
if (stale && tuiSessionID) {
|
|
384
|
-
titleRefresh.schedule(tuiSessionID, 0);
|
|
385
|
-
}
|
|
347
|
+
return;
|
|
386
348
|
},
|
|
387
349
|
onTuiSessionSelect: async (sessionID) => {
|
|
388
|
-
const prev = tuiSessionID;
|
|
389
|
-
tuiSessionID = sessionID;
|
|
390
|
-
armTuiTimer();
|
|
391
|
-
if (prev && prev !== sessionID) {
|
|
392
|
-
titleRefresh.schedule(prev, 0);
|
|
393
|
-
}
|
|
394
350
|
titleRefresh.schedule(sessionID, 0);
|
|
395
351
|
},
|
|
396
352
|
onMessageRemoved: async (info) => {
|
package/dist/storage.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
|
|
|
2
2
|
import { authFilePath, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
|
|
3
3
|
import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
|
|
4
4
|
export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
|
|
5
|
+
export declare function quotaConfigPaths(worktree: string, directory: string): string[];
|
|
5
6
|
export declare const defaultConfig: QuotaSidebarConfig;
|
|
6
7
|
export declare function defaultState(): QuotaSidebarState;
|
|
7
8
|
export declare function loadConfig(paths: string[]): Promise<QuotaSidebarConfig>;
|
package/dist/storage.js
CHANGED
|
@@ -6,6 +6,18 @@ import { dateKeyFromTimestamp, dateKeysInRange, dateStartFromKey, isDateKey, nor
|
|
|
6
6
|
import { parseQuotaCache } from './storage_parse.js';
|
|
7
7
|
import { authFilePath, chunkRootPathFromStateFile, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, } from './storage_paths.js';
|
|
8
8
|
export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
|
|
9
|
+
export function quotaConfigPaths(worktree, directory) {
|
|
10
|
+
const configDir = resolveOpencodeConfigDir();
|
|
11
|
+
const configOverride = process.env.OPENCODE_QUOTA_CONFIG?.trim();
|
|
12
|
+
return [
|
|
13
|
+
path.join(configDir, 'quota-sidebar.config.json'),
|
|
14
|
+
path.join(worktree, 'quota-sidebar.config.json'),
|
|
15
|
+
path.join(directory, 'quota-sidebar.config.json'),
|
|
16
|
+
path.join(worktree, '.opencode', 'quota-sidebar.config.json'),
|
|
17
|
+
path.join(directory, '.opencode', 'quota-sidebar.config.json'),
|
|
18
|
+
...(configOverride ? [path.resolve(configOverride)] : []),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
9
21
|
// ─── Default config ──────────────────────────────────────────────────────────
|
|
10
22
|
export const defaultConfig = {
|
|
11
23
|
sidebar: {
|