@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,9 +12,9 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
12
12
  Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to install it automatically on startup:
13
13
 
14
14
  ```json
15
- {
16
- "plugin": ["@leo000001/opencode-quota-sidebar@1.13.2"]
17
- }
15
+ {
16
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
17
+ }
18
18
  ```
19
19
 
20
20
  Note for OpenCode `>=1.2.15`: TUI settings (`theme`/`keybinds`/`tui`) moved to `tui.json`, but plugin loading still stays in `opencode.json` (`plugin: []`).
@@ -55,20 +55,22 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
55
 
56
56
  - Session title becomes multiline in sidebar:
57
57
  - line 1: original session title
58
- - line 2: Input/Output tokens
59
- - line 3: Cache Read tokens (only if non-zero)
60
- - line 4: Cache Write tokens (only if non-zero)
58
+ - line 2: blank separator
59
+ - line 3: Input/Output tokens
60
+ - line 4: Cache Read tokens (only if non-zero)
61
+ - line 5: Cache Write tokens (only if non-zero)
61
62
  - next lines: `Cache Coverage` (read/write cache models) and `Cache Read Coverage` (read-only cache models) when enough cache telemetry is available; mixed sessions can show both
62
63
  - next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
63
64
  - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
64
65
  - RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available; `Exp` remains date-only
65
66
  - 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.
66
- - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
67
+ - Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
67
68
  - `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
68
69
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
69
70
  - Custom tools:
70
71
  - `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
71
- - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
72
+ - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
73
+ - 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
72
74
  - Quota connectors:
73
75
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
74
76
  - GitHub Copilot OAuth (`/copilot_internal/user`)
@@ -88,12 +90,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
88
90
  - `titleEnabled`
89
91
  - `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
90
92
  - `quotaCache`
91
- - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
92
- - per-session title state (`baseTitle`, `lastAppliedTitle`)
93
- - `createdAt`
94
- - `parentID` (when the session is a subagent child session)
95
- - cached usage summary used by `quota_summary`
96
- - incremental aggregation cursor
93
+ - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
94
+ - per-session title state (`baseTitle`, `lastAppliedTitle`)
95
+ - `createdAt`
96
+ - `parentID` (when the session is a subagent child session)
97
+ - cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
98
+ - incremental aggregation cursor
99
+
100
+ Notes on cache coverage persistence:
101
+
102
+ - Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
103
+ - In those legacy cases, mixed read-only + read-write cache traffic may be attributed to a single fallback bucket until the session is recomputed from messages.
97
104
 
98
105
  Example tree:
99
106
 
@@ -296,7 +303,7 @@ Other defaults:
296
303
  - When OpenCode exposes a long-context tier like `context_over_200k`, the plugin uses that premium rate for the whole request once `input > 200000`, matching OpenCode's current pricing schema.
297
304
  - `quota.providers` is the extensible per-adapter switch map.
298
305
  - If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
299
- - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) usually reads those cached aggregates first, but a billing-cache version bump or missing/legacy API-cost data will trigger a rescan and persist refreshed values.
306
+ - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) recomputes range totals from session messages so period filtering follows message completion time; refreshed full-session usage may then be persisted back into day chunks when billing-cache refresh is needed.
300
307
 
301
308
  ### Buzz provider example
302
309
 
@@ -319,7 +326,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
319
326
  With that setup, the sidebar/toast quota line will look like:
320
327
 
321
328
  ```text
322
- Buzz Balance CNY 10.17
329
+ Buzz Balance 10.17
323
330
  ```
324
331
 
325
332
  ## Rendering examples
@@ -382,7 +389,7 @@ OpenAI
382
389
  5h 78% Rst 05:05
383
390
  Copilot
384
391
  Monthly 78% Rst 04-01
385
- Buzz Balance CNY 10.2
392
+ Buzz Balance 10.2
386
393
  ```
387
394
 
388
395
  Balance-style quota:
@@ -394,7 +401,7 @@ RC Balance $260
394
401
  Buzz balance quota:
395
402
 
396
403
  ```text
397
- Buzz Balance CNY 10.17
404
+ Buzz Balance 10.17
398
405
  ```
399
406
 
400
407
  Multi-detail quota (window + balance):
@@ -424,7 +431,7 @@ Quota is rendered inline as part of a single-line title:
424
431
  Mixed with Buzz balance:
425
432
 
426
433
  ```text
427
- <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance CNY 10.2
434
+ <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance 10.2
428
435
  ```
429
436
 
430
437
  `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.
package/dist/cost.js CHANGED
@@ -88,7 +88,8 @@ export function cacheCoverageModeFromRates(rates) {
88
88
  return 'none';
89
89
  }
90
90
  export function calcEquivalentApiCostForMessage(message, rates) {
91
- const effectiveRates = message.tokens.input > 200_000 && rates.contextOver200k
91
+ const effectiveRates = message.tokens.input + message.tokens.cache.read > 200_000 &&
92
+ rates.contextOver200k
92
93
  ? rates.contextOver200k
93
94
  : rates;
94
95
  // For providers that expose reasoning tokens separately, they are still
package/dist/format.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getCacheCoverageMetrics } from './usage.js';
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
4
  /** M6 fix: handle negative, NaN, Infinity gracefully. */
@@ -129,10 +129,18 @@ function fitLine(value, width) {
129
129
  return `${head}~`;
130
130
  }
131
131
  function formatCurrency(value, currency) {
132
- const safe = Number.isFinite(value) && value > 0 ? value : 0;
132
+ const safe = Number.isFinite(value) ? value : 0;
133
133
  const prefix = typeof currency === 'string' && currency ? currency : '$';
134
134
  if (safe === 0)
135
135
  return `${prefix}0.00`;
136
+ if (safe < 0) {
137
+ const abs = Math.abs(safe);
138
+ if (abs < 10)
139
+ return `-${prefix}${abs.toFixed(2)}`;
140
+ const one = abs.toFixed(1);
141
+ const trimmed = one.endsWith('.0') ? one.slice(0, -2) : one;
142
+ return `-${prefix}${trimmed}`;
143
+ }
136
144
  if (safe < 10)
137
145
  return `${prefix}${safe.toFixed(2)}`;
138
146
  const one = safe.toFixed(1);
@@ -153,6 +161,16 @@ function formatPercent(value, decimals = 1) {
153
161
  const pct = (safe * 100).toFixed(decimals);
154
162
  return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
155
163
  }
164
+ function formatQuotaPercent(value, options) {
165
+ const missing = options?.missing ?? '-';
166
+ if (value === undefined)
167
+ return missing;
168
+ if (!Number.isFinite(value) || value < 0)
169
+ return missing;
170
+ if (options?.rounded)
171
+ return `${Math.round(value)}%`;
172
+ return `${value.toFixed(options?.decimals ?? 1)}%`;
173
+ }
156
174
  function alignPairs(pairs, indent = ' ') {
157
175
  if (pairs.length === 0)
158
176
  return [];
@@ -170,15 +188,19 @@ function alignPairs(pairs, indent = ' ') {
170
188
  }
171
189
  function compactQuotaInline(quota) {
172
190
  const label = sanitizeLine(quotaDisplayLabel(quota));
173
- if (quota.status !== 'ok')
174
- return label;
191
+ if (quota.status !== 'ok') {
192
+ if (quota.status === 'error')
193
+ return `${label} Remaining ?`;
194
+ return `${label} ${sanitizeLine(quota.status)}`;
195
+ }
175
196
  if (quota.windows && quota.windows.length > 0) {
176
197
  const first = quota.windows[0];
177
198
  const showPercent = first.showPercent !== false;
178
199
  const firstLabel = sanitizeLine(first.label || '');
179
- const pct = first.remainingPercent === undefined
180
- ? undefined
181
- : `${Math.round(first.remainingPercent)}%`;
200
+ const pct = formatQuotaPercent(first.remainingPercent, {
201
+ rounded: true,
202
+ missing: '',
203
+ });
182
204
  const summary = showPercent
183
205
  ? [firstLabel, pct].filter(Boolean).join(' ')
184
206
  : firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
@@ -189,8 +211,12 @@ function compactQuotaInline(quota) {
189
211
  if (quota.balance) {
190
212
  return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
191
213
  }
192
- if (quota.remainingPercent !== undefined) {
193
- return `${label} ${Math.round(quota.remainingPercent)}%`;
214
+ const singlePercent = formatQuotaPercent(quota.remainingPercent, {
215
+ rounded: true,
216
+ missing: '',
217
+ });
218
+ if (singlePercent) {
219
+ return `${label} ${singlePercent}`;
194
220
  }
195
221
  return label;
196
222
  }
@@ -238,13 +264,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
238
264
  */
239
265
  export function renderSidebarTitle(baseTitle, usage, quotas, config) {
240
266
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
241
- const safeBaseTitle = stripAnsi(baseTitle || 'Session').split(/\r?\n/, 1)[0] || 'Session';
267
+ const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
242
268
  if (config.sidebar.multilineTitle !== true) {
243
- return renderSingleLineTitle(safeBaseTitle, usage, quotas, config, width);
269
+ const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
270
+ return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
244
271
  }
245
272
  const cacheMetrics = getCacheCoverageMetrics(usage);
246
273
  const lines = [];
247
- lines.push(fitLine(safeBaseTitle, width));
274
+ for (const line of safeBaseTitle.split(/\r?\n/)) {
275
+ lines.push(fitLine(line || 'Session', width));
276
+ }
248
277
  lines.push('');
249
278
  // Input / Output line
250
279
  const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
@@ -337,9 +366,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
337
366
  : undefined;
338
367
  const renderWindow = (win) => {
339
368
  const showPercent = win.showPercent !== false;
340
- const pct = win.remainingPercent === undefined
341
- ? '?'
342
- : `${Math.round(win.remainingPercent)}%`;
369
+ const pct = formatQuotaPercent(win.remainingPercent, { rounded: true });
343
370
  const parts = win.label
344
371
  ? showPercent
345
372
  ? [sanitizeLine(win.label), pct]
@@ -373,9 +400,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
373
400
  return maybeBreak(balanceText, [balanceText]);
374
401
  }
375
402
  // Fallback: single value from top-level remainingPercent
376
- const percent = quota.remainingPercent === undefined
377
- ? '?'
378
- : `${Math.round(quota.remainingPercent)}%`;
403
+ const percent = formatQuotaPercent(quota.remainingPercent, { rounded: true });
379
404
  const reset = compactReset(quota.resetAt, 'Rst');
380
405
  const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
381
406
  return maybeBreak(fallbackText, [fallbackText]);
@@ -409,9 +434,6 @@ function compactReset(iso, resetLabel, windowLabel) {
409
434
  return hhmm;
410
435
  return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
411
436
  }
412
- if (sameDay) {
413
- return `${two(value.getHours())}:${two(value.getMinutes())}`;
414
- }
415
437
  return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
416
438
  }
417
439
  function dateLine(iso) {
@@ -450,6 +472,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
450
472
  const measuredCostCell = (providerID, cost) => {
451
473
  const canonical = canonicalProviderID(providerID);
452
474
  const isSubscription = canonical === 'openai' ||
475
+ canonical === 'anthropic' ||
453
476
  canonical === 'github-copilot' ||
454
477
  rightCodeSubscriptionProviderIDs.has(providerID);
455
478
  if (isSubscription)
@@ -459,6 +482,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
459
482
  const isSubscriptionMeasuredProvider = (providerID) => {
460
483
  const canonical = canonicalProviderID(providerID);
461
484
  return (canonical === 'openai' ||
485
+ canonical === 'anthropic' ||
462
486
  canonical === 'github-copilot' ||
463
487
  rightCodeSubscriptionProviderIDs.has(providerID));
464
488
  };
@@ -486,39 +510,104 @@ export function renderMarkdownReport(period, usage, quotas, options) {
486
510
  return '-';
487
511
  return formatApiCostValue(usage.apiCost);
488
512
  };
489
- const providerRows = Object.values(usage.providers)
490
- .sort((a, b) => b.total - a.total)
513
+ const cacheCoverageCell = (provider) => {
514
+ const metrics = getProviderCacheCoverageMetrics(provider);
515
+ return metrics.cacheCoverage !== undefined
516
+ ? formatPercent(metrics.cacheCoverage, 1)
517
+ : '-';
518
+ };
519
+ const cacheReadCoverageCell = (provider) => {
520
+ const metrics = getProviderCacheCoverageMetrics(provider);
521
+ return metrics.cacheReadCoverage !== undefined
522
+ ? formatPercent(metrics.cacheReadCoverage, 1)
523
+ : '-';
524
+ };
525
+ const providerEntries = Object.values(usage.providers).sort((a, b) => b.total - a.total);
526
+ const highlightLines = () => {
527
+ const lines = [];
528
+ const providerLabel = (providerID) => quotaDisplayLabel({
529
+ providerID,
530
+ label: providerID,
531
+ status: 'ok',
532
+ checkedAt: 0,
533
+ });
534
+ const topApiCost = providerEntries
535
+ .filter((provider) => provider.apiCost > 0)
536
+ .sort((a, b) => b.apiCost - a.apiCost)[0];
537
+ if (topApiCost) {
538
+ lines.push(`- Top API cost: ${quotaDisplayLabel({
539
+ providerID: topApiCost.providerID,
540
+ label: topApiCost.providerID,
541
+ status: 'ok',
542
+ checkedAt: 0,
543
+ })} (${formatUsd(topApiCost.apiCost)})`);
544
+ }
545
+ const bestCacheCoverage = providerEntries
546
+ .map((provider) => ({
547
+ provider,
548
+ value: getProviderCacheCoverageMetrics(provider).cacheCoverage,
549
+ }))
550
+ .filter((entry) => entry.value !== undefined)
551
+ .sort((a, b) => b.value - a.value)[0];
552
+ if (bestCacheCoverage) {
553
+ lines.push(`- Best Cache Coverage: ${providerLabel(bestCacheCoverage.provider.providerID)} (${formatPercent(bestCacheCoverage.value, 1)})`);
554
+ }
555
+ const bestCacheReadCoverage = providerEntries
556
+ .map((provider) => ({
557
+ provider,
558
+ value: getProviderCacheCoverageMetrics(provider).cacheReadCoverage,
559
+ }))
560
+ .filter((entry) => entry.value !== undefined)
561
+ .sort((a, b) => b.value - a.value)[0];
562
+ if (bestCacheReadCoverage) {
563
+ lines.push(`- Best Cache Read Coverage: ${providerLabel(bestCacheReadCoverage.provider.providerID)} (${formatPercent(bestCacheReadCoverage.value, 1)})`);
564
+ }
565
+ const highestMeasured = providerEntries
566
+ .filter((provider) => measuredCostCell(provider.providerID, provider.cost) !== '-')
567
+ .sort((a, b) => b.cost - a.cost)[0];
568
+ if (highestMeasured && highestMeasured.cost > 0) {
569
+ lines.push(`- Highest measured cost: ${providerLabel(highestMeasured.providerID)} (${formatUsd(highestMeasured.cost)})`);
570
+ }
571
+ return lines;
572
+ };
573
+ const providerRows = providerEntries
491
574
  .map((provider) => {
492
575
  const providerID = mdCell(provider.providerID);
493
576
  return showCost
494
- ? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
577
+ ? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${cacheCoverageCell(provider)} | ${cacheReadCoverageCell(provider)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
495
578
  : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
496
579
  });
497
580
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
581
+ const displayLabel = quotaDisplayLabel(quota);
498
582
  // Multi-window detail
499
583
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
500
- return quota.windows.map((win) => {
584
+ const windowLines = quota.windows.map((win) => {
501
585
  if (win.showPercent === false) {
502
586
  const winLabel = win.label ? ` (${win.label})` : '';
503
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
587
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
504
588
  }
505
- const remaining = win.remainingPercent === undefined
506
- ? '-'
507
- : `${win.remainingPercent.toFixed(1)}%`;
589
+ const remaining = formatQuotaPercent(win.remainingPercent);
508
590
  const winLabel = win.label ? ` (${win.label})` : '';
509
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
591
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
510
592
  });
593
+ if (quota.balance) {
594
+ windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
595
+ }
596
+ return windowLines;
511
597
  }
512
598
  if (quota.status === 'ok' && quota.balance) {
513
599
  return [
514
- mdCell(`- ${quota.label}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
600
+ mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
515
601
  ];
516
602
  }
517
- const remaining = quota.remainingPercent === undefined
518
- ? '-'
519
- : `${quota.remainingPercent.toFixed(1)}%`;
603
+ if (quota.status !== 'ok') {
604
+ return [
605
+ mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
606
+ ];
607
+ }
608
+ const remaining = formatQuotaPercent(quota.remainingPercent);
520
609
  return [
521
- mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
610
+ mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
522
611
  ];
523
612
  });
524
613
  return [
@@ -541,17 +630,20 @@ export function renderMarkdownReport(period, usage, quotas, options) {
541
630
  `- API cost: ${apiCostSummaryValue()}`,
542
631
  ]
543
632
  : []),
633
+ ...(highlightLines().length > 0
634
+ ? ['', '### Highlights', ...highlightLines()]
635
+ : []),
544
636
  '',
545
637
  '### Usage by Provider',
546
638
  showCost
547
- ? '| Provider | Input | Output | Cache | Total | Measured Cost | API Cost |'
639
+ ? '| Provider | Input | Output | Cache | Total | Cache Coverage | Cache Read Coverage | Measured Cost | API Cost |'
548
640
  : '| Provider | Input | Output | Cache | Total |',
549
641
  showCost
550
- ? '|---|---:|---:|---:|---:|---:|---:|'
642
+ ? '|---|---:|---:|---:|---:|---:|---:|---:|---:|'
551
643
  : '|---|---:|---:|---:|---:|',
552
644
  ...(providerRows.length
553
645
  ? providerRows
554
- : [showCost ? '| - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
646
+ : [showCost ? '| - | - | - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
555
647
  '',
556
648
  '### Subscription Quota',
557
649
  ...(quotaLines.length
@@ -595,14 +687,6 @@ export function renderToastMessage(period, usage, quotas, options) {
595
687
  value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
596
688
  });
597
689
  }
598
- if (showCost) {
599
- if (usage.apiCost > 0) {
600
- tokenPairs.push({
601
- label: 'API Cost',
602
- value: formatApiCostValue(usage.apiCost),
603
- });
604
- }
605
- }
606
690
  lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
607
691
  if (showCost) {
608
692
  const costPairs = Object.values(usage.providers)
@@ -623,14 +707,35 @@ export function renderToastMessage(period, usage, quotas, options) {
623
707
  lines.push(fitLine(hasAnyUsage ? ' N/A (Copilot)' : ' -', width));
624
708
  }
625
709
  }
710
+ const providerCachePairs = Object.values(usage.providers)
711
+ .map((provider) => {
712
+ const metrics = getProviderCacheCoverageMetrics(provider);
713
+ const parts = [];
714
+ if (metrics.cacheCoverage !== undefined) {
715
+ parts.push(`Cov ${formatPercent(metrics.cacheCoverage, 1)}`);
716
+ }
717
+ if (metrics.cacheReadCoverage !== undefined) {
718
+ parts.push(`Read ${formatPercent(metrics.cacheReadCoverage, 1)}`);
719
+ }
720
+ if (parts.length === 0)
721
+ return undefined;
722
+ return {
723
+ label: displayShortLabel(provider.providerID),
724
+ value: parts.join(' '),
725
+ };
726
+ })
727
+ .filter((item) => Boolean(item));
728
+ if (providerCachePairs.length > 0) {
729
+ lines.push('');
730
+ lines.push(fitLine('Provider Cache', width));
731
+ lines.push(...alignPairs(providerCachePairs).map((line) => fitLine(line, width)));
732
+ }
626
733
  const quotaPairs = collapseQuotaSnapshots(quotas).flatMap((item) => {
627
734
  if (item.status === 'ok') {
628
735
  if (item.windows && item.windows.length > 0) {
629
736
  const pairs = item.windows.map((win, idx) => {
630
737
  const showPercent = win.showPercent !== false;
631
- const pct = win.remainingPercent === undefined
632
- ? '-'
633
- : `${win.remainingPercent.toFixed(1)}%`;
738
+ const pct = formatQuotaPercent(win.remainingPercent);
634
739
  const reset = compactReset(win.resetAt, win.resetLabel, win.label);
635
740
  const parts = [win.label];
636
741
  if (showPercent)
@@ -658,9 +763,7 @@ export function renderToastMessage(period, usage, quotas, options) {
658
763
  },
659
764
  ];
660
765
  }
661
- const percent = item.remainingPercent === undefined
662
- ? '-'
663
- : `${item.remainingPercent.toFixed(1)}%`;
766
+ const percent = formatQuotaPercent(item.remainingPercent);
664
767
  const reset = compactReset(item.resetAt, 'Rst');
665
768
  return [
666
769
  {
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { createPersistenceScheduler } from './persistence.js';
12
12
  import { createQuotaService } from './quota_service.js';
13
13
  import { createUsageService } from './usage_service.js';
14
14
  import { createTitleApplicator } from './title_apply.js';
15
+ const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
16
+ const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
15
17
  export async function QuotaSidebarPlugin(input) {
16
18
  const quotaRuntime = createQuotaRuntime();
17
19
  const configDir = resolveOpencodeConfigDir();
@@ -29,7 +31,7 @@ export async function QuotaSidebarPlugin(input) {
29
31
  const authPath = authFilePath(dataDir);
30
32
  const state = await loadState(statePath);
31
33
  // M2: evict old sessions on startup
32
- evictOldSessions(state, config.retentionDays);
34
+ const evictedOnStartup = evictOldSessions(state, config.retentionDays);
33
35
  const persistence = createPersistenceScheduler({
34
36
  statePath,
35
37
  state,
@@ -38,6 +40,9 @@ export async function QuotaSidebarPlugin(input) {
38
40
  const markDirty = persistence.markDirty;
39
41
  const scheduleSave = persistence.scheduleSave;
40
42
  const flushSave = persistence.flushSave;
43
+ if (evictedOnStartup > 0) {
44
+ scheduleSave();
45
+ }
41
46
  const RESTORE_TITLE_CONCURRENCY = 5;
42
47
  const quotaService = createQuotaService({
43
48
  quotaRuntime,
@@ -161,11 +166,62 @@ export async function QuotaSidebarPlugin(input) {
161
166
  restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
162
167
  });
163
168
  const titleRefresh = createTitleRefreshScheduler({
164
- apply: titleApplicator.applyTitle,
169
+ apply: async (sessionID) => {
170
+ await titleApplicator.applyTitle(sessionID);
171
+ },
165
172
  onError: swallow('titleRefresh'),
166
173
  });
167
174
  scheduleTitleRefresh = titleRefresh.schedule;
168
175
  const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
176
+ const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
177
+ const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
178
+ let startupTitleWork = Promise.resolve();
179
+ const runStartupRestore = async (attempt = 0) => {
180
+ const result = await restoreAllVisibleTitles({
181
+ abortIfEnabled: config.sidebar.enabled,
182
+ });
183
+ if (result.restored === result.attempted)
184
+ return;
185
+ debug(`startup restore incomplete: restored ${result.restored}/${result.attempted} touched titles while display mode remains OFF`);
186
+ if (state.titleEnabled || config.sidebar.enabled === false)
187
+ return;
188
+ if (attempt >= 2)
189
+ return;
190
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
191
+ await runStartupRestore(attempt + 1);
192
+ };
193
+ if (!state.titleEnabled || !config.sidebar.enabled) {
194
+ startupTitleWork = runStartupRestore().catch(swallow('startup:restoreAllVisibleTitles'));
195
+ }
196
+ else {
197
+ startupTitleWork = refreshAllTouchedTitles()
198
+ .then(() => undefined)
199
+ .catch(swallow('startup:refreshAllTouchedTitles'));
200
+ }
201
+ const shutdown = async () => {
202
+ await Promise.race([
203
+ startupTitleWork,
204
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
205
+ ]).catch(swallow('shutdown:startupTitleWork'));
206
+ await titleRefresh.waitForQuiescence().catch(swallow('shutdown:titleQuiescence'));
207
+ await flushSave().catch(swallow('shutdown:flushSave'));
208
+ };
209
+ const processWithHook = process;
210
+ const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||= new Set());
211
+ shutdownCallbacks.add(shutdown);
212
+ if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
213
+ processWithHook[SHUTDOWN_HOOK_KEY] = true;
214
+ process.once('beforeExit', () => {
215
+ void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback()));
216
+ });
217
+ for (const signal of ['SIGINT', 'SIGTERM']) {
218
+ process.once(signal, () => {
219
+ void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback())).finally(() => {
220
+ process.kill(process.pid, signal);
221
+ });
222
+ });
223
+ }
224
+ }
169
225
  const showToast = async (period, message) => {
170
226
  await input.client.tui
171
227
  .showToast({
@@ -217,10 +273,16 @@ export async function QuotaSidebarPlugin(input) {
217
273
  titleRefresh.cancel(session.id);
218
274
  const dateKey = state.sessionDateMap[session.id] ||
219
275
  dateKeyFromTimestamp(session.time.created);
276
+ state.deletedSessionDateMap[session.id] = dateKey;
220
277
  delete state.sessions[session.id];
221
278
  delete state.sessionDateMap[session.id];
279
+ markDirty(dateKey);
222
280
  scheduleSave();
223
- await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
281
+ const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
282
+ if (deletedFromChunk) {
283
+ delete state.deletedSessionDateMap[session.id];
284
+ scheduleSave();
285
+ }
224
286
  if (config.sidebar.includeChildren && session.parentID) {
225
287
  titleRefresh.schedule(session.parentID, 0);
226
288
  }
@@ -250,14 +312,25 @@ export async function QuotaSidebarPlugin(input) {
250
312
  state.titleEnabled = enabled;
251
313
  },
252
314
  scheduleSave,
315
+ flushSave,
316
+ waitForStartupTitleWork: () => startupTitleWork,
253
317
  refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
318
+ cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
319
+ flushScheduledTitleRefreshes: () => titleRefresh.flushScheduled(),
320
+ waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
321
+ waitForTitleRefreshQuiescence: () => titleRefresh.waitForQuiescence(),
254
322
  restoreAllVisibleTitles,
323
+ refreshAllTouchedTitles,
324
+ refreshAllVisibleTitles,
255
325
  showToast,
256
326
  summarizeForTool,
257
327
  getQuotaSnapshots,
258
328
  renderMarkdownReport,
259
329
  renderToastMessage,
260
- config,
330
+ config: {
331
+ sidebar: config.sidebar,
332
+ sidebarEnabled: config.sidebar.enabled,
333
+ },
261
334
  }),
262
335
  };
263
336
  }
@@ -4,6 +4,17 @@ export function createPersistenceScheduler(deps) {
4
4
  let stateDirty = false;
5
5
  let saveTimer;
6
6
  let saveInFlight = Promise.resolve();
7
+ const scheduleRetry = (delayMs = 1_000) => {
8
+ if (saveTimer)
9
+ return;
10
+ saveTimer = setTimeout(() => {
11
+ saveTimer = undefined;
12
+ void persist().catch((error) => {
13
+ debug(`persistState retry failed: ${String(error)}`);
14
+ scheduleRetry(Math.min(delayMs * 2, 10_000));
15
+ });
16
+ }, delayMs);
17
+ };
7
18
  /**
8
19
  * Capture and delete specific dirty keys instead of clearing the whole set.
9
20
  * Keys added between capture and write completion are preserved.
@@ -34,7 +45,10 @@ export function createPersistenceScheduler(deps) {
34
45
  clearTimeout(saveTimer);
35
46
  saveTimer = setTimeout(() => {
36
47
  saveTimer = undefined;
37
- void persist().catch(swallow('persistState:save'));
48
+ void persist().catch((error) => {
49
+ swallow('persistState:save')(error);
50
+ scheduleRetry();
51
+ });
38
52
  }, 200);
39
53
  };
40
54
  const flushSave = async () => {
package/dist/quota.js CHANGED
@@ -6,14 +6,12 @@ function resolveContext(providerID, providerOptions) {
6
6
  return { providerID, providerOptions };
7
7
  }
8
8
  function authCandidates(providerID, normalizedProviderID, adapterID) {
9
- const candidates = new Set([
10
- providerID,
11
- normalizedProviderID,
12
- adapterID,
13
- ]);
9
+ const candidates = new Set([providerID]);
14
10
  if (adapterID === 'github-copilot') {
15
11
  candidates.add('github-copilot-enterprise');
16
12
  }
13
+ candidates.add(normalizedProviderID);
14
+ candidates.add(adapterID);
17
15
  return [...candidates];
18
16
  }
19
17
  function pickAuth(providerID, normalizedProviderID, adapterID, authMap) {