@leo000001/opencode-quota-sidebar 2.0.20 → 2.0.26
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 +98 -30
- package/dist/events.d.ts +2 -0
- package/dist/events.js +12 -0
- package/dist/format.d.ts +18 -1
- package/dist/format.js +114 -46
- package/dist/index.js +55 -16
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +18 -0
- package/dist/storage_parse.d.ts +2 -2
- package/dist/storage_parse.js +90 -63
- package/dist/title_apply.d.ts +3 -1
- package/dist/title_apply.js +17 -3
- package/dist/tui.d.ts +5 -0
- package/dist/tui.js +181 -0
- package/dist/types.d.ts +14 -1
- package/package.json +18 -5
- package/quota-sidebar.config.example.json +1 -0
package/README.md
CHANGED
|
@@ -9,15 +9,27 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
If you use the OpenCode installer flow, the package manifest now advertises both `server` and `tui` targets.
|
|
13
|
+
|
|
14
|
+
If you configure files manually, add the server entry to `opencode.json` and the TUI entry to `tui.json`:
|
|
15
|
+
|
|
16
|
+
`opencode.json`
|
|
13
17
|
|
|
14
18
|
```json
|
|
15
19
|
{
|
|
16
|
-
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.
|
|
20
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
|
|
17
21
|
}
|
|
18
22
|
```
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
`tui.json`
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Note for OpenCode `>=1.2.15`: TUI settings and TUI plugins live in `tui.json`, while server plugins stay in `opencode.json`.
|
|
21
33
|
This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
|
|
22
34
|
|
|
23
35
|
If you prefer automatic upgrades, you can still use `@latest`, but pinning an exact version makes behavior easier to reproduce when debugging.
|
|
@@ -29,7 +41,9 @@ npm install
|
|
|
29
41
|
npm run build
|
|
30
42
|
```
|
|
31
43
|
|
|
32
|
-
Add the built file to your `opencode.json`:
|
|
44
|
+
Add the built server file to your `opencode.json` and the TUI file to your `tui.json`:
|
|
45
|
+
|
|
46
|
+
`opencode.json`
|
|
33
47
|
|
|
34
48
|
```json
|
|
35
49
|
{
|
|
@@ -37,7 +51,18 @@ Add the built file to your `opencode.json`:
|
|
|
37
51
|
}
|
|
38
52
|
```
|
|
39
53
|
|
|
40
|
-
|
|
54
|
+
`tui.json`
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"plugin": ["file:///ABSOLUTE/PATH/opencode-quota-sidebar/dist/tui.js"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
On Windows, use forward slashes, for example:
|
|
63
|
+
|
|
64
|
+
- `file:///D:/Lab/opencode-quota-sidebar/dist/index.js`
|
|
65
|
+
- `file:///D:/Lab/opencode-quota-sidebar/dist/tui.js`
|
|
41
66
|
|
|
42
67
|
## Supported quota providers
|
|
43
68
|
|
|
@@ -55,18 +80,16 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
55
80
|
|
|
56
81
|
## Features
|
|
57
82
|
|
|
58
|
-
- TUI
|
|
59
|
-
-
|
|
60
|
-
- line
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
- TUI is always rendered as compact multiline; Desktop is always rendered as compact monitoring-style single-line. This behavior no longer depends on `sidebar.multilineTitle`
|
|
69
|
-
- Web UI currently cannot be reliably detected by the plugin, so it follows the non-desktop multiline path
|
|
83
|
+
- TUI sidebar can render a dedicated block layout instead of stuffing telemetry into the shared title:
|
|
84
|
+
- `TITLE`: clean base session title
|
|
85
|
+
- `CONTEXT`: one compact line such as `242k tok 24% ctx`
|
|
86
|
+
- `USAGE`: compact request/input/output/cache lines such as `R184 I189k O53.2k`, `CR31.4k CW3.2k Cd66%`, `Est $12.8`
|
|
87
|
+
- `QUOTA`: one compact provider group per provider, with indented continuation lines for multi-window quotas or balances
|
|
88
|
+
- while active, the TUI plugin temporarily deactivates the built-in `internal:sidebar-context` block so the custom one-line context row does not duplicate it
|
|
89
|
+
- 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`.
|
|
90
|
+
- `sidebar.titleMode=multiline` is still available as a legacy fallback when you explicitly want the old multiline title decoration path.
|
|
91
|
+
- `sidebar.titleMode` can force `auto`, `multiline`, or `compact` if the heuristic does not match your workflow.
|
|
92
|
+
- 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.
|
|
70
93
|
- 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.
|
|
71
94
|
- Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cached ratios are available), and `Quota`
|
|
72
95
|
- 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
|
|
@@ -144,7 +167,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
|
|
|
144
167
|
## Compatibility
|
|
145
168
|
|
|
146
169
|
- Node.js: >= 18 (for `fetch` + `AbortController`)
|
|
147
|
-
- OpenCode: plugin SDK `@opencode-ai/plugin` ^1.
|
|
170
|
+
- OpenCode: plugin SDK `@opencode-ai/plugin` ^1.3.5
|
|
148
171
|
- OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
|
|
149
172
|
|
|
150
173
|
## Force refresh after npm update
|
|
@@ -153,7 +176,7 @@ If `npm view @leo000001/opencode-quota-sidebar version` shows a newer version bu
|
|
|
153
176
|
|
|
154
177
|
Recommended recovery steps:
|
|
155
178
|
|
|
156
|
-
1. Pin the target plugin version in `opencode.json
|
|
179
|
+
1. Pin the target plugin version in every config file that loads it (`opencode.json` and, if used manually, `tui.json`).
|
|
157
180
|
2. Fully exit OpenCode.
|
|
158
181
|
3. Delete any cached installed copies of the plugin.
|
|
159
182
|
4. Start OpenCode again so it reinstalls the package.
|
|
@@ -249,7 +272,8 @@ Sidebar defaults:
|
|
|
249
272
|
|
|
250
273
|
- `sidebar.enabled`: `true`
|
|
251
274
|
- `sidebar.width`: `36` (clamped to `20`-`60`)
|
|
252
|
-
- `sidebar.
|
|
275
|
+
- `sidebar.titleMode`: `auto` (`auto`/`multiline`/`compact`)
|
|
276
|
+
- `sidebar.multilineTitle`: `true` (legacy compatibility field; title style is now chosen automatically)
|
|
253
277
|
- `sidebar.showCost`: `true`
|
|
254
278
|
- `sidebar.showQuota`: `true`
|
|
255
279
|
- `sidebar.wrapQuotaLines`: `true`
|
|
@@ -257,8 +281,8 @@ Sidebar defaults:
|
|
|
257
281
|
- `sidebar.childrenMaxDepth`: `6` (clamped to `1`-`32`)
|
|
258
282
|
- `sidebar.childrenMaxSessions`: `128` (clamped to `0`-`2000`)
|
|
259
283
|
- `sidebar.childrenConcurrency`: `5` (clamped to `1`-`10`)
|
|
260
|
-
- `sidebar.desktopCompact.recentRequests`: `50` (
|
|
261
|
-
- `sidebar.desktopCompact.recentMinutes`: `60` (
|
|
284
|
+
- `sidebar.desktopCompact.recentRequests`: `50` (compact single-line titles)
|
|
285
|
+
- `sidebar.desktopCompact.recentMinutes`: `60` (compact single-line titles)
|
|
262
286
|
|
|
263
287
|
Quota defaults:
|
|
264
288
|
|
|
@@ -282,6 +306,7 @@ Other defaults:
|
|
|
282
306
|
"sidebar": {
|
|
283
307
|
"enabled": true,
|
|
284
308
|
"width": 36,
|
|
309
|
+
"titleMode": "auto",
|
|
285
310
|
"multilineTitle": true,
|
|
286
311
|
"showCost": true,
|
|
287
312
|
"showQuota": true,
|
|
@@ -320,16 +345,20 @@ Other defaults:
|
|
|
320
345
|
|
|
321
346
|
### Notes
|
|
322
347
|
|
|
323
|
-
- `sidebar.showCost` controls API-cost visibility in
|
|
348
|
+
- `sidebar.showCost` controls API-cost visibility in the TUI `USAGE` block, the compact shared title, `quota_summary` markdown report, and toast message.
|
|
324
349
|
- `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).
|
|
325
350
|
- `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
|
|
326
|
-
- `sidebar.
|
|
351
|
+
- `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.
|
|
352
|
+
- The TUI plugin renders `TITLE`, `CONTEXT`, `USAGE`, and `QUOTA` blocks in the sidebar and temporarily disables the built-in `internal:sidebar-context` block while it is active.
|
|
353
|
+
- 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.
|
|
354
|
+
- `sidebar.multilineTitle` is kept for backward compatibility, but `sidebar.titleMode` now controls the active policy.
|
|
327
355
|
- `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
|
|
328
356
|
- `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
|
|
329
357
|
- `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
|
|
330
358
|
- `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
|
|
331
359
|
- `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
|
|
332
|
-
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` control which recently used providers remain visible in
|
|
360
|
+
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` control which recently used providers remain visible in compact single-line titles.
|
|
361
|
+
- `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` only control provider filtering inside compact titles; they do not affect the dedicated TUI sidebar panel.
|
|
333
362
|
- `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
|
|
334
363
|
- API cost bills reasoning tokens at the output rate (same as completion tokens).
|
|
335
364
|
- 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`.
|
|
@@ -368,7 +397,46 @@ These examples show the quota block portion of the sidebar title.
|
|
|
368
397
|
|
|
369
398
|
### TUI layout
|
|
370
399
|
|
|
371
|
-
|
|
400
|
+
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"`.
|
|
401
|
+
|
|
402
|
+
Typical layout:
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
TITLE
|
|
406
|
+
Fix quota adapter matching
|
|
407
|
+
CONTEXT
|
|
408
|
+
242k tok 24% ctx
|
|
409
|
+
USAGE
|
|
410
|
+
R184 I189k O53.2k
|
|
411
|
+
CR31.4k CW3.2k Cd66%
|
|
412
|
+
Est $12.8
|
|
413
|
+
QUOTA
|
|
414
|
+
OAI 5h80 R16:20
|
|
415
|
+
W70 R04-03
|
|
416
|
+
Cop M78 R04-01
|
|
417
|
+
RC D$88.9/$60 E02-27
|
|
418
|
+
B260
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Force modes
|
|
422
|
+
|
|
423
|
+
If you prefer a stable policy instead of the auto heuristic:
|
|
424
|
+
|
|
425
|
+
```json
|
|
426
|
+
{
|
|
427
|
+
"sidebar": {
|
|
428
|
+
"titleMode": "compact"
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
```json
|
|
434
|
+
{
|
|
435
|
+
"sidebar": {
|
|
436
|
+
"titleMode": "multiline"
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
372
440
|
|
|
373
441
|
0 providers (no quota data):
|
|
374
442
|
|
|
@@ -449,9 +517,9 @@ Cop unavailable
|
|
|
449
517
|
OAI ?
|
|
450
518
|
```
|
|
451
519
|
|
|
452
|
-
###
|
|
520
|
+
### Compact shared title mode
|
|
453
521
|
|
|
454
|
-
Desktop
|
|
522
|
+
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:
|
|
455
523
|
|
|
456
524
|
```text
|
|
457
525
|
<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
|
|
@@ -466,8 +534,8 @@ Shorthand rules:
|
|
|
466
534
|
- `B260` / `B¥10.2` = balance
|
|
467
535
|
- `Cd66%` = cached ratio (`cache.read / (input + cache.read)`)
|
|
468
536
|
- `Est$0.12` = equivalent API cost estimate
|
|
469
|
-
-
|
|
470
|
-
- Order is `base | quota... | usage-summary
|
|
537
|
+
- Compact shared titles omit `R/I/O/CR/CW`; the dedicated TUI sidebar keeps the richer `CONTEXT / USAGE / QUOTA` breakdown.
|
|
538
|
+
- Order is `base | quota... | usage-summary` for compact shared titles.
|
|
471
539
|
|
|
472
540
|
`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.
|
|
473
541
|
|
package/dist/events.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ 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>;
|
|
7
|
+
onTuiSessionSelect: (sessionID: string) => Promise<void>;
|
|
6
8
|
onMessageRemoved: (info: {
|
|
7
9
|
sessionID: string;
|
|
8
10
|
messageID?: string;
|
package/dist/events.js
CHANGED
|
@@ -3,6 +3,7 @@ function isAssistantMessage(message) {
|
|
|
3
3
|
}
|
|
4
4
|
export function createEventDispatcher(handlers) {
|
|
5
5
|
return async (event) => {
|
|
6
|
+
const tui = event;
|
|
6
7
|
if (event.type === 'session.created') {
|
|
7
8
|
await handlers.onSessionCreated(event.properties.info);
|
|
8
9
|
return;
|
|
@@ -15,6 +16,17 @@ export function createEventDispatcher(handlers) {
|
|
|
15
16
|
await handlers.onSessionDeleted(event.properties.info);
|
|
16
17
|
return;
|
|
17
18
|
}
|
|
19
|
+
if (tui.type === 'tui.prompt.append' || tui.type === 'tui.command.execute') {
|
|
20
|
+
await handlers.onTuiActivity();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (tui.type === 'tui.session.select') {
|
|
24
|
+
if (typeof tui.properties?.sessionID !== 'string')
|
|
25
|
+
return;
|
|
26
|
+
await handlers.onTuiSessionSelect(tui.properties.sessionID);
|
|
27
|
+
await handlers.onTuiActivity();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
18
30
|
if (event.type === 'message.removed') {
|
|
19
31
|
const props = event.properties;
|
|
20
32
|
await handlers.onMessageRemoved({
|
package/dist/format.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
|
|
2
2
|
import { type UsageSummary } from './usage.js';
|
|
3
|
+
export type TitleView = 'multiline' | 'compact';
|
|
4
|
+
export declare const TUI_ACTIVE_MS: number;
|
|
5
|
+
/**
|
|
6
|
+
* Truncate `value` to at most `width` terminal cells.
|
|
7
|
+
* Keep plain text only (no ANSI) to avoid renderer corruption.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fitLine(value: string, width: number): string;
|
|
3
10
|
export declare function isDesktopClient(): boolean;
|
|
11
|
+
export declare function resolveTitleView(opts: {
|
|
12
|
+
config: QuotaSidebarConfig;
|
|
13
|
+
sessionID?: string;
|
|
14
|
+
tuiSessionID?: string;
|
|
15
|
+
tuiActiveAt?: number;
|
|
16
|
+
now?: number;
|
|
17
|
+
}): TitleView;
|
|
4
18
|
export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, config: QuotaSidebarConfig, now?: number): string[];
|
|
5
19
|
/**
|
|
6
20
|
* Render sidebar title with multi-line token breakdown.
|
|
@@ -13,7 +27,10 @@ export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, con
|
|
|
13
27
|
* $3.81 as API cost (only if showCost=true)
|
|
14
28
|
* OpenAI Remaining 78% (only if quota available)
|
|
15
29
|
*/
|
|
16
|
-
export declare function renderSidebarTitle(baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig): string;
|
|
30
|
+
export declare function renderSidebarTitle(baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig, view?: TitleView): string;
|
|
31
|
+
export declare function renderSidebarContextLine(tokens: number, percent: number | undefined, width: number): string;
|
|
32
|
+
export declare function renderSidebarUsageLines(usage: UsageSummary, config: QuotaSidebarConfig): string[];
|
|
33
|
+
export declare function renderSidebarQuotaLines(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): string[];
|
|
17
34
|
export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
|
|
18
35
|
showCost?: boolean;
|
|
19
36
|
}): string;
|
package/dist/format.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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;
|
|
4
5
|
/** M6 fix: handle negative, NaN, Infinity gracefully. */
|
|
5
6
|
function shortNumber(value, decimals = 1) {
|
|
6
7
|
if (!Number.isFinite(value) || value < 0)
|
|
@@ -113,7 +114,7 @@ function truncateToCellWidth(value, width) {
|
|
|
113
114
|
* Truncate `value` to at most `width` terminal cells.
|
|
114
115
|
* Keep plain text only (no ANSI) to avoid renderer corruption.
|
|
115
116
|
*/
|
|
116
|
-
function fitLine(value, width) {
|
|
117
|
+
export function fitLine(value, width) {
|
|
117
118
|
if (width <= 0)
|
|
118
119
|
return '';
|
|
119
120
|
const safe = sanitizeLine(value);
|
|
@@ -156,6 +157,14 @@ function formatApiCostValue(value) {
|
|
|
156
157
|
function formatApiCostLine(value) {
|
|
157
158
|
return `${formatApiCostValue(value)} as API cost`;
|
|
158
159
|
}
|
|
160
|
+
function trimTrailingZeroUnit(value) {
|
|
161
|
+
return value
|
|
162
|
+
.replace(/(\d+)\.0(?=[km]\b)/i, '$1')
|
|
163
|
+
.replace(/(\d+)\.0(?=$)/, '$1');
|
|
164
|
+
}
|
|
165
|
+
function panelNumber(value) {
|
|
166
|
+
return trimTrailingZeroUnit(shortNumber(value, 1));
|
|
167
|
+
}
|
|
159
168
|
function formatRequestsLabel(value, short = false) {
|
|
160
169
|
const count = shortNumber(value, 1);
|
|
161
170
|
return short ? `Req ${count}` : `Requests ${count}`;
|
|
@@ -163,6 +172,15 @@ function formatRequestsLabel(value, short = false) {
|
|
|
163
172
|
export function isDesktopClient() {
|
|
164
173
|
return process.env.OPENCODE_CLIENT === 'desktop';
|
|
165
174
|
}
|
|
175
|
+
export function resolveTitleView(opts) {
|
|
176
|
+
if (opts.config.sidebar.titleMode === 'compact')
|
|
177
|
+
return 'compact';
|
|
178
|
+
if (opts.config.sidebar.titleMode === 'multiline')
|
|
179
|
+
return 'multiline';
|
|
180
|
+
if (isDesktopClient())
|
|
181
|
+
return 'compact';
|
|
182
|
+
return 'compact';
|
|
183
|
+
}
|
|
166
184
|
function desktopCompactSettings(config) {
|
|
167
185
|
return {
|
|
168
186
|
recentRequests: Math.max(1, config.sidebar.desktopCompact?.recentRequests ?? 50),
|
|
@@ -363,6 +381,63 @@ function formatPercent(value, decimals = 1) {
|
|
|
363
381
|
const pct = (safe * 100).toFixed(decimals);
|
|
364
382
|
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
365
383
|
}
|
|
384
|
+
function fitsLine(value, width) {
|
|
385
|
+
return stringCellWidth(sanitizeLine(value)) <= width;
|
|
386
|
+
}
|
|
387
|
+
function usageDetailLines(usage, cacheMetrics, options) {
|
|
388
|
+
const width = options.width;
|
|
389
|
+
const numberToken = options.numberToken || sidebarNumber;
|
|
390
|
+
const costToken = options.costToken || ((value) => `Est${formatApiCostValue(value)}`);
|
|
391
|
+
const groups = [];
|
|
392
|
+
groups.push([
|
|
393
|
+
`R${shortNumber(usage.assistantMessages, 1)}`,
|
|
394
|
+
`I${numberToken(usage.input)}`,
|
|
395
|
+
`O${numberToken(usage.output)}`,
|
|
396
|
+
]);
|
|
397
|
+
const secondary = [];
|
|
398
|
+
const pushCacheRead = () => {
|
|
399
|
+
if (usage.cacheRead > 0) {
|
|
400
|
+
secondary.push(`CR${numberToken(usage.cacheRead)}`);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const pushCacheWrite = () => {
|
|
404
|
+
if (usage.cacheWrite > 0) {
|
|
405
|
+
secondary.push(`CW${numberToken(usage.cacheWrite)}`);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
if (options.cacheReadFirst) {
|
|
409
|
+
pushCacheRead();
|
|
410
|
+
pushCacheWrite();
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
pushCacheWrite();
|
|
414
|
+
pushCacheRead();
|
|
415
|
+
}
|
|
416
|
+
if (cacheMetrics.cachedRatio !== undefined) {
|
|
417
|
+
secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
|
|
418
|
+
}
|
|
419
|
+
if (secondary.length > 0)
|
|
420
|
+
groups.push(secondary);
|
|
421
|
+
if (options.showCost && usage.apiCost > 0) {
|
|
422
|
+
groups.push([costToken(usage.apiCost)]);
|
|
423
|
+
}
|
|
424
|
+
const packed = [];
|
|
425
|
+
for (const group of groups) {
|
|
426
|
+
let current = '';
|
|
427
|
+
for (const token of group) {
|
|
428
|
+
const candidate = current ? `${current} ${token}` : token;
|
|
429
|
+
if (!current || fitsLine(candidate, width)) {
|
|
430
|
+
current = candidate;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
packed.push(current);
|
|
434
|
+
current = token;
|
|
435
|
+
}
|
|
436
|
+
if (current)
|
|
437
|
+
packed.push(current);
|
|
438
|
+
}
|
|
439
|
+
return packed;
|
|
440
|
+
}
|
|
366
441
|
function formatQuotaPercent(value, options) {
|
|
367
442
|
const missing = options?.missing ?? '-';
|
|
368
443
|
if (value === undefined)
|
|
@@ -462,10 +537,11 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
|
462
537
|
* $3.81 as API cost (only if showCost=true)
|
|
463
538
|
* OpenAI Remaining 78% (only if quota available)
|
|
464
539
|
*/
|
|
465
|
-
export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
540
|
+
export function renderSidebarTitle(baseTitle, usage, quotas, config, view) {
|
|
466
541
|
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
467
542
|
const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
|
|
468
|
-
|
|
543
|
+
const mode = view || resolveTitleView({ config });
|
|
544
|
+
if (mode === 'compact') {
|
|
469
545
|
const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
|
|
470
546
|
return renderDesktopCompactTitle(singleLineBase, usage, quotas, config, width);
|
|
471
547
|
}
|
|
@@ -507,51 +583,43 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
507
583
|
lines.push(fitLine(line, width));
|
|
508
584
|
}
|
|
509
585
|
}
|
|
510
|
-
function fitsLine(value, width) {
|
|
511
|
-
return stringCellWidth(sanitizeLine(value)) <= width;
|
|
512
|
-
}
|
|
513
|
-
function usageDetailLines(usage, cacheMetrics, options) {
|
|
514
|
-
const width = options.width;
|
|
515
|
-
const groups = [];
|
|
516
|
-
groups.push([
|
|
517
|
-
`R${shortNumber(usage.assistantMessages, 1)}`,
|
|
518
|
-
`I${sidebarNumber(usage.input)}`,
|
|
519
|
-
`O${sidebarNumber(usage.output)}`,
|
|
520
|
-
]);
|
|
521
|
-
const secondary = [];
|
|
522
|
-
if (usage.cacheWrite > 0) {
|
|
523
|
-
secondary.push(`CW${sidebarNumber(usage.cacheWrite)}`);
|
|
524
|
-
}
|
|
525
|
-
if (usage.cacheRead > 0) {
|
|
526
|
-
secondary.push(`CR${sidebarNumber(usage.cacheRead)}`);
|
|
527
|
-
}
|
|
528
|
-
if (cacheMetrics.cachedRatio !== undefined) {
|
|
529
|
-
secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
|
|
530
|
-
}
|
|
531
|
-
if (secondary.length > 0)
|
|
532
|
-
groups.push(secondary);
|
|
533
|
-
if (options.showCost && usage.apiCost > 0) {
|
|
534
|
-
groups.push([`Est${formatApiCostValue(usage.apiCost)}`]);
|
|
535
|
-
}
|
|
536
|
-
const packed = [];
|
|
537
|
-
for (const group of groups) {
|
|
538
|
-
let current = '';
|
|
539
|
-
for (const token of group) {
|
|
540
|
-
const candidate = current ? `${current} ${token}` : token;
|
|
541
|
-
if (!current || fitsLine(candidate, width)) {
|
|
542
|
-
current = candidate;
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
packed.push(current);
|
|
546
|
-
current = token;
|
|
547
|
-
}
|
|
548
|
-
if (current)
|
|
549
|
-
packed.push(current);
|
|
550
|
-
}
|
|
551
|
-
return packed;
|
|
552
|
-
}
|
|
553
586
|
return lines.join('\n');
|
|
554
587
|
}
|
|
588
|
+
export function renderSidebarContextLine(tokens, percent, width) {
|
|
589
|
+
const parts = [`${panelNumber(tokens)} tok`];
|
|
590
|
+
if (percent !== undefined && Number.isFinite(percent) && percent >= 0) {
|
|
591
|
+
parts.push(`${Math.round(percent)}% ctx`);
|
|
592
|
+
}
|
|
593
|
+
return fitLine(parts.join(' '), width);
|
|
594
|
+
}
|
|
595
|
+
export function renderSidebarUsageLines(usage, config) {
|
|
596
|
+
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
597
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
598
|
+
return usageDetailLines(usage, cacheMetrics, {
|
|
599
|
+
width,
|
|
600
|
+
showCost: config.sidebar.showCost,
|
|
601
|
+
numberToken: panelNumber,
|
|
602
|
+
costToken: (value) => `Est ${formatApiCostValue(value)}`,
|
|
603
|
+
cacheReadFirst: true,
|
|
604
|
+
}).map((line) => fitLine(line, width));
|
|
605
|
+
}
|
|
606
|
+
export function renderSidebarQuotaLines(quotas, config) {
|
|
607
|
+
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
608
|
+
const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
|
|
609
|
+
const labelWidth = visibleQuotas.reduce((max, item) => {
|
|
610
|
+
const label = compactProviderLabel(item);
|
|
611
|
+
return Math.max(max, stringCellWidth(label));
|
|
612
|
+
}, 0);
|
|
613
|
+
return visibleQuotas
|
|
614
|
+
.flatMap((item) => compactQuotaWide(item, labelWidth, {
|
|
615
|
+
width,
|
|
616
|
+
wrapLines: config.sidebar.wrapQuotaLines,
|
|
617
|
+
forceWrapped: false,
|
|
618
|
+
compactDetails: true,
|
|
619
|
+
}))
|
|
620
|
+
.filter((line) => Boolean(line))
|
|
621
|
+
.map((line) => fitLine(line, width));
|
|
622
|
+
}
|
|
555
623
|
/**
|
|
556
624
|
* Multi-window quota format for sidebar.
|
|
557
625
|
*
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { renderMarkdownReport, renderSidebarTitle, renderToastMessage, } from './format.js';
|
|
1
|
+
import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, TUI_ACTIVE_MS, } 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,6 +116,23 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
126
116
|
});
|
|
127
117
|
const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
|
|
128
118
|
const summarizeForTool = usageService.summarizeForTool;
|
|
119
|
+
let tuiSessionID;
|
|
120
|
+
let tuiActiveAt = 0;
|
|
121
|
+
let tuiTimer;
|
|
122
|
+
const armTuiTimer = () => {
|
|
123
|
+
if (tuiTimer)
|
|
124
|
+
clearTimeout(tuiTimer);
|
|
125
|
+
if (!tuiSessionID)
|
|
126
|
+
return;
|
|
127
|
+
tuiTimer = setTimeout(() => {
|
|
128
|
+
const id = tuiSessionID;
|
|
129
|
+
tuiActiveAt = 0;
|
|
130
|
+
tuiTimer = undefined;
|
|
131
|
+
if (id)
|
|
132
|
+
scheduleTitleRefresh(id, 0);
|
|
133
|
+
}, TUI_ACTIVE_MS);
|
|
134
|
+
tuiTimer.unref?.();
|
|
135
|
+
};
|
|
129
136
|
// title apply / refresh lifecycle
|
|
130
137
|
let scheduleTitleRefresh = (sessionID, delay = 250) => {
|
|
131
138
|
void sessionID;
|
|
@@ -161,6 +168,7 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
161
168
|
markDirty,
|
|
162
169
|
scheduleSave,
|
|
163
170
|
renderSidebarTitle,
|
|
171
|
+
getTitleView: (sessionID) => resolveTitleView({ config, sessionID, tuiSessionID, tuiActiveAt }),
|
|
164
172
|
getQuotaSnapshots,
|
|
165
173
|
summarizeSessionUsageForDisplay,
|
|
166
174
|
scheduleParentRefreshIfSafe,
|
|
@@ -200,15 +208,20 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
200
208
|
.catch(swallow('startup:refreshAllTouchedTitles'));
|
|
201
209
|
}
|
|
202
210
|
const shutdown = async () => {
|
|
211
|
+
if (tuiTimer)
|
|
212
|
+
clearTimeout(tuiTimer);
|
|
203
213
|
await Promise.race([
|
|
204
214
|
startupTitleWork,
|
|
205
215
|
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
206
216
|
]).catch(swallow('shutdown:startupTitleWork'));
|
|
207
|
-
await titleRefresh
|
|
217
|
+
await titleRefresh
|
|
218
|
+
.waitForQuiescence()
|
|
219
|
+
.catch(swallow('shutdown:titleQuiescence'));
|
|
208
220
|
await flushSave().catch(swallow('shutdown:flushSave'));
|
|
209
221
|
};
|
|
210
222
|
const processWithHook = process;
|
|
211
|
-
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
|
|
223
|
+
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
|
|
224
|
+
new Set());
|
|
212
225
|
shutdownCallbacks.add(shutdown);
|
|
213
226
|
if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
|
|
214
227
|
processWithHook[SHUTDOWN_HOOK_KEY] = true;
|
|
@@ -280,7 +293,8 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
280
293
|
if (expiryLines.length === 0)
|
|
281
294
|
return;
|
|
282
295
|
sessionState.expiryToastShown = true;
|
|
283
|
-
const dateKey = state.sessionDateMap[sessionID] ||
|
|
296
|
+
const dateKey = state.sessionDateMap[sessionID] ||
|
|
297
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
284
298
|
state.sessionDateMap[sessionID] = dateKey;
|
|
285
299
|
markDirty(dateKey);
|
|
286
300
|
scheduleSave();
|
|
@@ -327,6 +341,14 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
327
341
|
},
|
|
328
342
|
onSessionDeleted: async (session) => {
|
|
329
343
|
await flushSave().catch(swallow('onSessionDeleted:flushSave'));
|
|
344
|
+
if (tuiSessionID === session.id) {
|
|
345
|
+
tuiSessionID = undefined;
|
|
346
|
+
tuiActiveAt = 0;
|
|
347
|
+
if (tuiTimer) {
|
|
348
|
+
clearTimeout(tuiTimer);
|
|
349
|
+
tuiTimer = undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
330
352
|
descendantsResolver.invalidateForAncestors(session.parentID);
|
|
331
353
|
descendantsResolver.invalidateForAncestors(session.id);
|
|
332
354
|
usageService.forgetSession(session.id);
|
|
@@ -348,6 +370,23 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
348
370
|
titleRefresh.schedule(session.parentID, 0);
|
|
349
371
|
}
|
|
350
372
|
},
|
|
373
|
+
onTuiActivity: async () => {
|
|
374
|
+
const stale = Boolean(tuiSessionID) && Date.now() - tuiActiveAt > TUI_ACTIVE_MS;
|
|
375
|
+
tuiActiveAt = Date.now();
|
|
376
|
+
armTuiTimer();
|
|
377
|
+
if (stale && tuiSessionID) {
|
|
378
|
+
titleRefresh.schedule(tuiSessionID, 0);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
onTuiSessionSelect: async (sessionID) => {
|
|
382
|
+
const prev = tuiSessionID;
|
|
383
|
+
tuiSessionID = sessionID;
|
|
384
|
+
armTuiTimer();
|
|
385
|
+
if (prev && prev !== sessionID) {
|
|
386
|
+
titleRefresh.schedule(prev, 0);
|
|
387
|
+
}
|
|
388
|
+
titleRefresh.schedule(sessionID, 0);
|
|
389
|
+
},
|
|
351
390
|
onMessageRemoved: async (info) => {
|
|
352
391
|
usageService.markForceRescan(info.sessionID);
|
|
353
392
|
titleRefresh.schedule(info.sessionID, 0);
|
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,11 +6,24 @@ 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: {
|
|
12
24
|
enabled: true,
|
|
13
25
|
width: 36,
|
|
26
|
+
titleMode: 'auto',
|
|
14
27
|
multilineTitle: true,
|
|
15
28
|
showCost: true,
|
|
16
29
|
showQuota: true,
|
|
@@ -88,6 +101,11 @@ export async function loadConfig(paths) {
|
|
|
88
101
|
sidebar: {
|
|
89
102
|
enabled: asBoolean(sidebar.enabled, base.sidebar.enabled),
|
|
90
103
|
width: Math.max(20, Math.min(60, asNumber(sidebar.width, base.sidebar.width))),
|
|
104
|
+
titleMode: sidebar.titleMode === 'compact' ||
|
|
105
|
+
sidebar.titleMode === 'multiline' ||
|
|
106
|
+
sidebar.titleMode === 'auto'
|
|
107
|
+
? sidebar.titleMode
|
|
108
|
+
: (base.sidebar.titleMode ?? 'auto'),
|
|
91
109
|
multilineTitle: asBoolean(sidebar.multilineTitle, base.sidebar.multilineTitle ?? true),
|
|
92
110
|
showCost: asBoolean(sidebar.showCost, base.sidebar.showCost),
|
|
93
111
|
showQuota: asBoolean(sidebar.showQuota, base.sidebar.showQuota),
|
package/dist/storage_parse.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { SessionState } from './types.js';
|
|
1
|
+
import type { QuotaSnapshot, SessionState } from './types.js';
|
|
2
2
|
export declare function parseSessionState(value: unknown): SessionState | undefined;
|
|
3
|
-
export declare function parseQuotaCache(value: unknown): Record<string,
|
|
3
|
+
export declare function parseQuotaCache(value: unknown): Record<string, QuotaSnapshot>;
|
package/dist/storage_parse.js
CHANGED
|
@@ -105,6 +105,92 @@ function parseCachedUsage(value) {
|
|
|
105
105
|
providers,
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
function parseQuotaSnapshot(value) {
|
|
109
|
+
if (!isRecord(value))
|
|
110
|
+
return undefined;
|
|
111
|
+
const checkedAt = asNumber(value.checkedAt, 0);
|
|
112
|
+
if (!checkedAt)
|
|
113
|
+
return undefined;
|
|
114
|
+
const status = value.status;
|
|
115
|
+
if (status !== 'ok' &&
|
|
116
|
+
status !== 'unavailable' &&
|
|
117
|
+
status !== 'unsupported' &&
|
|
118
|
+
status !== 'error') {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const label = typeof value.label === 'string' ? value.label : '';
|
|
122
|
+
const adapterID = typeof value.adapterID === 'string' ? value.adapterID : undefined;
|
|
123
|
+
const shortLabel = typeof value.shortLabel === 'string' ? value.shortLabel : undefined;
|
|
124
|
+
const sortOrder = typeof value.sortOrder === 'number' ? value.sortOrder : undefined;
|
|
125
|
+
const balance = isRecord(value.balance)
|
|
126
|
+
? {
|
|
127
|
+
amount: typeof value.balance.amount === 'number' ? value.balance.amount : 0,
|
|
128
|
+
currency: typeof value.balance.currency === 'string'
|
|
129
|
+
? value.balance.currency
|
|
130
|
+
: '$',
|
|
131
|
+
}
|
|
132
|
+
: undefined;
|
|
133
|
+
const windows = Array.isArray(value.windows)
|
|
134
|
+
? value.windows
|
|
135
|
+
.filter((window) => isRecord(window))
|
|
136
|
+
.map((window) => ({
|
|
137
|
+
label: typeof window.label === 'string' ? window.label : '',
|
|
138
|
+
showPercent: typeof window.showPercent === 'boolean'
|
|
139
|
+
? window.showPercent
|
|
140
|
+
: undefined,
|
|
141
|
+
resetLabel: typeof window.resetLabel === 'string'
|
|
142
|
+
? window.resetLabel
|
|
143
|
+
: undefined,
|
|
144
|
+
note: typeof window.note === 'string' ? window.note : undefined,
|
|
145
|
+
remainingPercent: typeof window.remainingPercent === 'number'
|
|
146
|
+
? window.remainingPercent
|
|
147
|
+
: undefined,
|
|
148
|
+
usedPercent: typeof window.usedPercent === 'number'
|
|
149
|
+
? window.usedPercent
|
|
150
|
+
: undefined,
|
|
151
|
+
resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
|
|
152
|
+
}))
|
|
153
|
+
.filter((window) => window.label || window.remainingPercent !== undefined)
|
|
154
|
+
: undefined;
|
|
155
|
+
return {
|
|
156
|
+
providerID: typeof value.providerID === 'string' ? value.providerID : label,
|
|
157
|
+
adapterID,
|
|
158
|
+
label,
|
|
159
|
+
shortLabel,
|
|
160
|
+
sortOrder,
|
|
161
|
+
status,
|
|
162
|
+
checkedAt,
|
|
163
|
+
remainingPercent: typeof value.remainingPercent === 'number'
|
|
164
|
+
? value.remainingPercent
|
|
165
|
+
: undefined,
|
|
166
|
+
usedPercent: typeof value.usedPercent === 'number' ? value.usedPercent : undefined,
|
|
167
|
+
resetAt: typeof value.resetAt === 'string' ? value.resetAt : undefined,
|
|
168
|
+
expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
|
|
169
|
+
balance,
|
|
170
|
+
note: typeof value.note === 'string' ? value.note : undefined,
|
|
171
|
+
windows,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function parseQuotaSnapshots(value) {
|
|
175
|
+
if (!Array.isArray(value))
|
|
176
|
+
return undefined;
|
|
177
|
+
const parsed = value
|
|
178
|
+
.map((item) => parseQuotaSnapshot(item))
|
|
179
|
+
.filter((item) => Boolean(item));
|
|
180
|
+
return parsed.length > 0 ? parsed : [];
|
|
181
|
+
}
|
|
182
|
+
function parseSidebarPanel(value) {
|
|
183
|
+
if (!isRecord(value))
|
|
184
|
+
return undefined;
|
|
185
|
+
const updatedAt = asNumber(value.updatedAt, 0);
|
|
186
|
+
if (!updatedAt)
|
|
187
|
+
return undefined;
|
|
188
|
+
return {
|
|
189
|
+
updatedAt,
|
|
190
|
+
usage: parseCachedUsage(value.usage),
|
|
191
|
+
quotas: parseQuotaSnapshots(value.quotas),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
108
194
|
function parseCursor(value) {
|
|
109
195
|
if (!isRecord(value))
|
|
110
196
|
return undefined;
|
|
@@ -137,75 +223,16 @@ export function parseSessionState(value) {
|
|
|
137
223
|
usage: parseCachedUsage(value.usage),
|
|
138
224
|
dirty: value.dirty === true,
|
|
139
225
|
cursor: parseCursor(value.cursor),
|
|
226
|
+
sidebarPanel: parseSidebarPanel(value.sidebarPanel),
|
|
140
227
|
};
|
|
141
228
|
}
|
|
142
229
|
export function parseQuotaCache(value) {
|
|
143
230
|
const raw = isRecord(value) ? value : {};
|
|
144
231
|
return Object.entries(raw).reduce((acc, [key, item]) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const checkedAt = asNumber(item.checkedAt, 0);
|
|
148
|
-
if (!checkedAt)
|
|
149
|
-
return acc;
|
|
150
|
-
const status = item.status;
|
|
151
|
-
if (status !== 'ok' &&
|
|
152
|
-
status !== 'unavailable' &&
|
|
153
|
-
status !== 'unsupported' &&
|
|
154
|
-
status !== 'error') {
|
|
232
|
+
const parsed = parseQuotaSnapshot(item);
|
|
233
|
+
if (!parsed)
|
|
155
234
|
return acc;
|
|
156
|
-
}
|
|
157
|
-
const label = typeof item.label === 'string' ? item.label : key;
|
|
158
|
-
const adapterID = typeof item.adapterID === 'string' ? item.adapterID : undefined;
|
|
159
|
-
const shortLabel = typeof item.shortLabel === 'string' ? item.shortLabel : undefined;
|
|
160
|
-
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : undefined;
|
|
161
|
-
const balance = isRecord(item.balance)
|
|
162
|
-
? {
|
|
163
|
-
amount: typeof item.balance.amount === 'number' ? item.balance.amount : 0,
|
|
164
|
-
currency: typeof item.balance.currency === 'string'
|
|
165
|
-
? item.balance.currency
|
|
166
|
-
: '$',
|
|
167
|
-
}
|
|
168
|
-
: undefined;
|
|
169
|
-
const windows = Array.isArray(item.windows)
|
|
170
|
-
? item.windows
|
|
171
|
-
.filter((window) => isRecord(window))
|
|
172
|
-
.map((window) => ({
|
|
173
|
-
label: typeof window.label === 'string' ? window.label : '',
|
|
174
|
-
showPercent: typeof window.showPercent === 'boolean'
|
|
175
|
-
? window.showPercent
|
|
176
|
-
: undefined,
|
|
177
|
-
resetLabel: typeof window.resetLabel === 'string'
|
|
178
|
-
? window.resetLabel
|
|
179
|
-
: undefined,
|
|
180
|
-
note: typeof window.note === 'string' ? window.note : undefined,
|
|
181
|
-
remainingPercent: typeof window.remainingPercent === 'number'
|
|
182
|
-
? window.remainingPercent
|
|
183
|
-
: undefined,
|
|
184
|
-
usedPercent: typeof window.usedPercent === 'number'
|
|
185
|
-
? window.usedPercent
|
|
186
|
-
: undefined,
|
|
187
|
-
resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
|
|
188
|
-
}))
|
|
189
|
-
.filter((window) => window.label || window.remainingPercent !== undefined)
|
|
190
|
-
: undefined;
|
|
191
|
-
acc[key] = {
|
|
192
|
-
providerID: typeof item.providerID === 'string' ? item.providerID : key,
|
|
193
|
-
adapterID,
|
|
194
|
-
label,
|
|
195
|
-
shortLabel,
|
|
196
|
-
sortOrder,
|
|
197
|
-
status,
|
|
198
|
-
checkedAt,
|
|
199
|
-
remainingPercent: typeof item.remainingPercent === 'number'
|
|
200
|
-
? item.remainingPercent
|
|
201
|
-
: undefined,
|
|
202
|
-
usedPercent: typeof item.usedPercent === 'number' ? item.usedPercent : undefined,
|
|
203
|
-
resetAt: typeof item.resetAt === 'string' ? item.resetAt : undefined,
|
|
204
|
-
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
|
205
|
-
balance,
|
|
206
|
-
note: typeof item.note === 'string' ? item.note : undefined,
|
|
207
|
-
windows,
|
|
208
|
-
};
|
|
235
|
+
acc[key] = parsed.label ? parsed : { ...parsed, label: key };
|
|
209
236
|
return acc;
|
|
210
237
|
}, {});
|
|
211
238
|
}
|
package/dist/title_apply.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
2
|
import type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot, SessionState } from './types.js';
|
|
3
3
|
import type { UsageSummary } from './usage.js';
|
|
4
|
+
import { type TitleView } from './format.js';
|
|
4
5
|
export declare function createTitleApplicator(deps: {
|
|
5
6
|
state: QuotaSidebarState;
|
|
6
7
|
config: QuotaSidebarConfig;
|
|
@@ -9,7 +10,8 @@ export declare function createTitleApplicator(deps: {
|
|
|
9
10
|
ensureSessionState: (sessionID: string, title: string, createdAt: number, parentID?: string | null) => SessionState;
|
|
10
11
|
markDirty: (dateKey: string | undefined) => void;
|
|
11
12
|
scheduleSave: () => void;
|
|
12
|
-
renderSidebarTitle: (baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig) => string;
|
|
13
|
+
renderSidebarTitle: (baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig, view?: TitleView) => string;
|
|
14
|
+
getTitleView?: (sessionID: string) => TitleView;
|
|
13
15
|
getQuotaSnapshots: (providerIDs: string[], options?: {
|
|
14
16
|
allowDefault?: boolean;
|
|
15
17
|
}) => Promise<QuotaSnapshot[]>;
|
package/dist/title_apply.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
|
|
2
|
+
import { toCachedSessionUsage } from './usage.js';
|
|
2
3
|
import { swallow, debug, mapConcurrent } from './helpers.js';
|
|
3
|
-
import {
|
|
4
|
+
import { resolveTitleView, selectDesktopCompactProviderIDs, } from './format.js';
|
|
4
5
|
export function createTitleApplicator(deps) {
|
|
5
6
|
const pendingAppliedTitle = new Map();
|
|
6
7
|
const recentRestore = new Map();
|
|
@@ -8,6 +9,11 @@ export function createTitleApplicator(deps) {
|
|
|
8
9
|
pendingAppliedTitle.delete(sessionID);
|
|
9
10
|
recentRestore.delete(sessionID);
|
|
10
11
|
};
|
|
12
|
+
const cloneQuotas = (quotas) => quotas.map((quota) => ({
|
|
13
|
+
...quota,
|
|
14
|
+
balance: quota.balance ? { ...quota.balance } : undefined,
|
|
15
|
+
windows: quota.windows?.map((win) => ({ ...win })),
|
|
16
|
+
}));
|
|
11
17
|
const applyTitle = async (sessionID) => {
|
|
12
18
|
if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
|
|
13
19
|
return false;
|
|
@@ -73,13 +79,21 @@ export function createTitleApplicator(deps) {
|
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
|
|
76
|
-
const
|
|
82
|
+
const view = deps.getTitleView?.(sessionID) ??
|
|
83
|
+
resolveTitleView({ config: deps.config, sessionID });
|
|
84
|
+
const quotaProviders = Array.from(new Set(view === 'compact'
|
|
77
85
|
? selectDesktopCompactProviderIDs(usage, deps.config)
|
|
78
86
|
: Object.keys(usage.providers)));
|
|
79
87
|
const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
|
|
80
88
|
? await deps.getQuotaSnapshots(quotaProviders)
|
|
81
89
|
: [];
|
|
82
|
-
|
|
90
|
+
sessionState.sidebarPanel = {
|
|
91
|
+
updatedAt: Date.now(),
|
|
92
|
+
usage: toCachedSessionUsage(usage),
|
|
93
|
+
quotas: cloneQuotas(quotas),
|
|
94
|
+
};
|
|
95
|
+
stateMutated = true;
|
|
96
|
+
const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
|
|
83
97
|
if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
|
|
84
98
|
return false;
|
|
85
99
|
if (canonicalizeTitleForCompare(nextTitle) ===
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { createMemo, createResource, createSignal, For, onCleanup, Show, } from 'solid-js';
|
|
5
|
+
import { fitLine, renderSidebarContextLine, renderSidebarQuotaLines, renderSidebarUsageLines, } from './format.js';
|
|
6
|
+
import { loadConfig, loadState, quotaConfigPaths, resolveOpencodeDataDir, stateFilePath, } from './storage.js';
|
|
7
|
+
import { normalizeBaseTitle } from './title.js';
|
|
8
|
+
import { fromCachedSessionUsage } from './usage.js';
|
|
9
|
+
const id = 'leo.quota-sidebar';
|
|
10
|
+
const INTERNAL_CONTEXT_PLUGIN_ID = 'internal:sidebar-context';
|
|
11
|
+
const SECTION_INDENT = 2;
|
|
12
|
+
const DEFAULT_WIDTH = 36;
|
|
13
|
+
function latestAssistantWithOutput(messages) {
|
|
14
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
15
|
+
const message = messages[index];
|
|
16
|
+
if (!message || message.role !== 'assistant')
|
|
17
|
+
continue;
|
|
18
|
+
if (message.tokens.output <= 0)
|
|
19
|
+
continue;
|
|
20
|
+
return message;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const stateCache = new Map();
|
|
25
|
+
async function loadStateCached(filePath) {
|
|
26
|
+
const stat = await fs.stat(filePath).catch(() => undefined);
|
|
27
|
+
const mtimeMs = stat?.mtimeMs ?? -1;
|
|
28
|
+
const cached = stateCache.get(filePath);
|
|
29
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
30
|
+
return cached.state;
|
|
31
|
+
const state = await loadState(filePath);
|
|
32
|
+
stateCache.set(filePath, { mtimeMs, state });
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
function directoryPath(api) {
|
|
36
|
+
return api.state.path.directory || process.cwd();
|
|
37
|
+
}
|
|
38
|
+
function worktreePath(api) {
|
|
39
|
+
return api.state.path.worktree || directoryPath(api);
|
|
40
|
+
}
|
|
41
|
+
function panelConfig(config) {
|
|
42
|
+
return {
|
|
43
|
+
...config,
|
|
44
|
+
sidebar: {
|
|
45
|
+
...config.sidebar,
|
|
46
|
+
width: Math.max(8, config.sidebar.width - SECTION_INDENT),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function loadSidebarPanel(api, sessionID) {
|
|
51
|
+
const config = await loadConfig(quotaConfigPaths(worktreePath(api), directoryPath(api)));
|
|
52
|
+
const state = await loadStateCached(stateFilePath(resolveOpencodeDataDir()));
|
|
53
|
+
const session = state.sessions[sessionID];
|
|
54
|
+
const enabled = config.sidebar.enabled && state.titleEnabled;
|
|
55
|
+
const width = Math.max(8, config.sidebar.width - SECTION_INDENT);
|
|
56
|
+
if (!enabled || !session?.sidebarPanel?.usage) {
|
|
57
|
+
return {
|
|
58
|
+
enabled,
|
|
59
|
+
width,
|
|
60
|
+
usageLines: [],
|
|
61
|
+
quotaLines: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const usage = fromCachedSessionUsage(session.sidebarPanel.usage);
|
|
65
|
+
const usageLines = renderSidebarUsageLines(usage, panelConfig(config));
|
|
66
|
+
const quotaLines = renderSidebarQuotaLines(session.sidebarPanel.quotas || [], panelConfig(config));
|
|
67
|
+
return {
|
|
68
|
+
enabled,
|
|
69
|
+
width,
|
|
70
|
+
usageLines,
|
|
71
|
+
quotaLines,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function useSidebarPanelData(api, sessionID) {
|
|
75
|
+
const [refresh, setRefresh] = createSignal(0);
|
|
76
|
+
const [panel] = createResource(() => `${sessionID()}:${refresh()}`, async () => loadSidebarPanel(api, sessionID()));
|
|
77
|
+
let timer;
|
|
78
|
+
const scheduleRefresh = () => {
|
|
79
|
+
if (timer)
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
timer = setTimeout(() => setRefresh((value) => value + 1), 250);
|
|
82
|
+
};
|
|
83
|
+
const unsubscribers = [
|
|
84
|
+
api.event.on('session.updated', (event) => {
|
|
85
|
+
if (event.properties.info.id === sessionID())
|
|
86
|
+
scheduleRefresh();
|
|
87
|
+
}),
|
|
88
|
+
api.event.on('message.updated', (event) => {
|
|
89
|
+
if (event.properties.info.sessionID === sessionID())
|
|
90
|
+
scheduleRefresh();
|
|
91
|
+
}),
|
|
92
|
+
api.event.on('message.removed', (event) => {
|
|
93
|
+
if (event.properties.sessionID === sessionID())
|
|
94
|
+
scheduleRefresh();
|
|
95
|
+
}),
|
|
96
|
+
api.event.on('tui.session.select', (event) => {
|
|
97
|
+
if (event.properties.sessionID === sessionID())
|
|
98
|
+
scheduleRefresh();
|
|
99
|
+
}),
|
|
100
|
+
];
|
|
101
|
+
onCleanup(() => {
|
|
102
|
+
if (timer)
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
for (const unsubscribe of unsubscribers)
|
|
105
|
+
unsubscribe();
|
|
106
|
+
});
|
|
107
|
+
return panel;
|
|
108
|
+
}
|
|
109
|
+
function sectionHeading(api, value) {
|
|
110
|
+
return _jsx("text", { fg: api.theme.current.textMuted, children: value });
|
|
111
|
+
}
|
|
112
|
+
function ContextSection(props) {
|
|
113
|
+
const messages = createMemo(() => props.api.state.session.messages(props.sessionID));
|
|
114
|
+
const contextLine = createMemo(() => {
|
|
115
|
+
const last = latestAssistantWithOutput(messages());
|
|
116
|
+
if (!last)
|
|
117
|
+
return undefined;
|
|
118
|
+
const tokens = last.tokens.input +
|
|
119
|
+
last.tokens.output +
|
|
120
|
+
last.tokens.reasoning +
|
|
121
|
+
last.tokens.cache.read +
|
|
122
|
+
last.tokens.cache.write;
|
|
123
|
+
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID];
|
|
124
|
+
const percent = model?.limit.context && model.limit.context > 0
|
|
125
|
+
? (tokens / model.limit.context) * 100
|
|
126
|
+
: undefined;
|
|
127
|
+
return renderSidebarContextLine(tokens, percent, props.width());
|
|
128
|
+
});
|
|
129
|
+
return (_jsx(Show, { when: contextLine(), children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'CONTEXT'), _jsx("box", { paddingLeft: SECTION_INDENT, children: _jsx("text", { fg: props.api.theme.current.text, children: contextLine() }) })] }) }));
|
|
130
|
+
}
|
|
131
|
+
function SidebarContentView(props) {
|
|
132
|
+
const panel = useSidebarPanelData(props.api, () => props.sessionID);
|
|
133
|
+
const width = createMemo(() => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT);
|
|
134
|
+
return (_jsx(Show, { when: panel()?.enabled, children: _jsxs("box", { gap: 0, children: [_jsx(ContextSection, { api: props.api, sessionID: props.sessionID, width: width }), _jsx(Show, { when: (panel()?.usageLines.length || 0) > 0, children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'USAGE'), _jsx("box", { paddingLeft: SECTION_INDENT, gap: 0, children: _jsx(For, { each: panel()?.usageLines || [], children: (line) => (_jsx("text", { fg: props.api.theme.current.text, children: line })) }) })] }) }), _jsx(Show, { when: (panel()?.quotaLines.length || 0) > 0, children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'QUOTA'), _jsx("box", { paddingLeft: SECTION_INDENT, gap: 0, children: _jsx(For, { each: panel()?.quotaLines || [], children: (line) => (_jsx("text", { fg: props.api.theme.current.text, children: line })) }) })] }) })] }) }));
|
|
135
|
+
}
|
|
136
|
+
function SidebarTitleView(props) {
|
|
137
|
+
const panel = useSidebarPanelData(props.api, () => props.sessionID);
|
|
138
|
+
const width = createMemo(() => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT);
|
|
139
|
+
const titleLines = createMemo(() => {
|
|
140
|
+
const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session';
|
|
141
|
+
return baseTitle
|
|
142
|
+
.split(/\r?\n/)
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.map((line) => fitLine(line, width()));
|
|
145
|
+
});
|
|
146
|
+
const shareLine = createMemo(() => props.shareURL ? fitLine(props.shareURL, width()) : undefined);
|
|
147
|
+
return (_jsx(Show, { when: panel()?.enabled, fallback: _jsxs("box", { gap: 0, paddingRight: 1, children: [_jsx(For, { each: titleLines(), children: (line) => _jsx("text", { fg: props.api.theme.current.text, children: line }) }), _jsx(Show, { when: shareLine(), children: _jsx("text", { fg: props.api.theme.current.textMuted, children: shareLine() }) })] }), children: _jsxs("box", { gap: 0, paddingRight: 1, children: [sectionHeading(props.api, 'TITLE'), _jsxs("box", { paddingLeft: SECTION_INDENT, gap: 0, children: [_jsx(For, { each: titleLines(), children: (line) => _jsx("text", { fg: props.api.theme.current.text, children: line }) }), _jsx(Show, { when: shareLine(), children: _jsx("text", { fg: props.api.theme.current.textMuted, children: shareLine() }) })] })] }) }));
|
|
148
|
+
}
|
|
149
|
+
const tui = async (api) => {
|
|
150
|
+
const contextPlugin = api.plugins
|
|
151
|
+
.list()
|
|
152
|
+
.find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID);
|
|
153
|
+
const shouldRestoreContext = Boolean(contextPlugin?.enabled && contextPlugin?.active);
|
|
154
|
+
if (contextPlugin?.active) {
|
|
155
|
+
void api.plugins.deactivate(INTERNAL_CONTEXT_PLUGIN_ID).catch(() => false);
|
|
156
|
+
}
|
|
157
|
+
api.lifecycle.onDispose(() => {
|
|
158
|
+
if (!shouldRestoreContext)
|
|
159
|
+
return;
|
|
160
|
+
return api.plugins
|
|
161
|
+
.activate(INTERNAL_CONTEXT_PLUGIN_ID)
|
|
162
|
+
.then(() => undefined)
|
|
163
|
+
.catch(() => undefined);
|
|
164
|
+
});
|
|
165
|
+
api.slots.register({
|
|
166
|
+
order: 100,
|
|
167
|
+
slots: {
|
|
168
|
+
sidebar_title(_ctx, props) {
|
|
169
|
+
return (_jsx(SidebarTitleView, { api: api, sessionID: props.session_id, title: props.title, shareURL: props.share_url }));
|
|
170
|
+
},
|
|
171
|
+
sidebar_content(_ctx, props) {
|
|
172
|
+
return _jsx(SidebarContentView, { api: api, sessionID: props.session_id });
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
const plugin = {
|
|
178
|
+
id,
|
|
179
|
+
tui,
|
|
180
|
+
};
|
|
181
|
+
export default plugin;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type QuotaStatus = 'ok' | 'unavailable' | 'unsupported' | 'error';
|
|
2
|
+
export type SidebarTitleMode = 'auto' | 'multiline' | 'compact';
|
|
2
3
|
export type QuotaWindow = {
|
|
3
4
|
label: string;
|
|
4
5
|
/** Set false when this window line should not render a trailing percentage. */
|
|
@@ -108,6 +109,11 @@ export type CachedSessionUsage = {
|
|
|
108
109
|
recentProviders?: RecentProviderEvent[];
|
|
109
110
|
providers: Record<string, CachedProviderUsage>;
|
|
110
111
|
};
|
|
112
|
+
export type SidebarPanelState = {
|
|
113
|
+
updatedAt: number;
|
|
114
|
+
usage?: CachedSessionUsage;
|
|
115
|
+
quotas?: QuotaSnapshot[];
|
|
116
|
+
};
|
|
111
117
|
/** Tracks incremental aggregation cursor for a session (P1). */
|
|
112
118
|
export type IncrementalCursor = {
|
|
113
119
|
/** ID of the last processed assistant message. */
|
|
@@ -128,6 +134,8 @@ export type SessionState = SessionTitleState & {
|
|
|
128
134
|
dirty?: boolean;
|
|
129
135
|
/** Incremental aggregation cursor (P1). */
|
|
130
136
|
cursor?: IncrementalCursor;
|
|
137
|
+
/** Cached TUI sidebar panel payload for the session. */
|
|
138
|
+
sidebarPanel?: SidebarPanelState;
|
|
131
139
|
};
|
|
132
140
|
export type SessionDayChunk = {
|
|
133
141
|
version: 1;
|
|
@@ -148,6 +156,11 @@ export type QuotaSidebarConfig = {
|
|
|
148
156
|
sidebar: {
|
|
149
157
|
enabled: boolean;
|
|
150
158
|
width: number;
|
|
159
|
+
/**
|
|
160
|
+
* `auto`: keep the shared session title compact and let the dedicated TUI
|
|
161
|
+
* sidebar plugin render the rich panel layout.
|
|
162
|
+
*/
|
|
163
|
+
titleMode?: SidebarTitleMode;
|
|
151
164
|
/**
|
|
152
165
|
* Legacy switch retained for compatibility.
|
|
153
166
|
* TUI keeps a compact multiline sidebar layout; Desktop keeps a compact
|
|
@@ -166,7 +179,7 @@ export type QuotaSidebarConfig = {
|
|
|
166
179
|
childrenMaxSessions: number;
|
|
167
180
|
/** Concurrency for fetching descendant session messages (bounded). */
|
|
168
181
|
childrenConcurrency: number;
|
|
169
|
-
/**
|
|
182
|
+
/** Compact single-line title selection window by request count/time. */
|
|
170
183
|
desktopCompact?: {
|
|
171
184
|
recentRequests?: number;
|
|
172
185
|
recentMinutes?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leo000001/opencode-quota-sidebar",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.26",
|
|
4
4
|
"description": "OpenCode plugin that shows quota and token usage in session titles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./tui": {
|
|
14
|
+
"types": "./dist/tui.d.ts",
|
|
15
|
+
"default": "./dist/tui.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
18
|
+
"oc-plugin": [
|
|
19
|
+
"server",
|
|
20
|
+
"tui"
|
|
21
|
+
],
|
|
14
22
|
"files": [
|
|
15
23
|
"dist/*.js",
|
|
16
24
|
"dist/*.d.ts",
|
|
@@ -56,12 +64,17 @@
|
|
|
56
64
|
"node": ">=18"
|
|
57
65
|
},
|
|
58
66
|
"peerDependencies": {
|
|
59
|
-
"@opencode-ai/plugin": "^1.
|
|
60
|
-
"@opencode-ai/sdk": "^1.
|
|
67
|
+
"@opencode-ai/plugin": "^1.3.5",
|
|
68
|
+
"@opencode-ai/sdk": "^1.3.5"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@opentui/core": "^0.1.92",
|
|
72
|
+
"@opentui/solid": "^0.1.92",
|
|
73
|
+
"solid-js": "^1.9.10"
|
|
61
74
|
},
|
|
62
75
|
"devDependencies": {
|
|
63
|
-
"@opencode-ai/plugin": "^1.
|
|
64
|
-
"@opencode-ai/sdk": "^1.
|
|
76
|
+
"@opencode-ai/plugin": "^1.3.5",
|
|
77
|
+
"@opencode-ai/sdk": "^1.3.5",
|
|
65
78
|
"@types/node": "^22.13.10",
|
|
66
79
|
"typescript": "^5.8.2"
|
|
67
80
|
}
|