@leo000001/opencode-quota-sidebar 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,6 +164,38 @@ Resolution order (low -> high):
164
164
 
165
165
  Values are layered; later sources override earlier ones.
166
166
 
167
+ ## Defaults
168
+
169
+ If you do not provide any config file, the plugin uses the built-in defaults below.
170
+
171
+ Sidebar defaults:
172
+
173
+ - `sidebar.enabled`: `true`
174
+ - `sidebar.width`: `36` (clamped to `20`-`60`)
175
+ - `sidebar.multilineTitle`: `true`
176
+ - `sidebar.showCost`: `true`
177
+ - `sidebar.showQuota`: `true`
178
+ - `sidebar.wrapQuotaLines`: `true`
179
+ - `sidebar.includeChildren`: `true`
180
+ - `sidebar.childrenMaxDepth`: `6` (clamped to `1`-`32`)
181
+ - `sidebar.childrenMaxSessions`: `128` (clamped to `0`-`2000`)
182
+ - `sidebar.childrenConcurrency`: `5` (clamped to `1`-`10`)
183
+
184
+ Quota defaults:
185
+
186
+ - `quota.refreshMs`: `300000` (clamped to `>=30000`)
187
+ - `quota.includeOpenAI`: `true`
188
+ - `quota.includeCopilot`: `true`
189
+ - `quota.includeAnthropic`: `true`
190
+ - `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled`)
191
+ - `quota.refreshAccessToken`: `false`
192
+ - `quota.requestTimeoutMs`: `8000` (clamped to `>=1000`)
193
+
194
+ Other defaults:
195
+
196
+ - `toast.durationMs`: `12000` (clamped to `>=1000`)
197
+ - `retentionDays`: `730`
198
+
167
199
  Example config:
168
200
 
169
201
  ```json
@@ -171,8 +203,10 @@ Example config:
171
203
  "sidebar": {
172
204
  "enabled": true,
173
205
  "width": 36,
206
+ "multilineTitle": true,
174
207
  "showCost": true,
175
208
  "showQuota": true,
209
+ "wrapQuotaLines": true,
176
210
  "includeChildren": true,
177
211
  "childrenMaxDepth": 6,
178
212
  "childrenMaxSessions": 128,
@@ -202,6 +236,8 @@ Notes:
202
236
 
203
237
  - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
204
238
  - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
239
+ - `sidebar.multilineTitle` controls multi-line sidebar layout (default: `true`). Set `false` for compact single-line title.
240
+ - `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
205
241
  - `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
206
242
  - `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
207
243
  - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
@@ -211,6 +247,81 @@ Notes:
211
247
  - `quota.providers` is the extensible per-adapter switch map.
212
248
  - 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.
213
249
 
250
+ ## Rendering examples
251
+
252
+ These examples show the quota block portion of the sidebar title.
253
+
254
+ ### `sidebar.multilineTitle=true`
255
+
256
+ 0 providers (no quota data):
257
+
258
+ ```text
259
+ (no quota block)
260
+ ```
261
+
262
+ 1 provider, 1 window (fits):
263
+
264
+ ```text
265
+ Copilot Monthly 78% Rst 04-01
266
+ ```
267
+
268
+ 1 provider, multi-window (for example OpenAI 5h + Weekly):
269
+
270
+ ```text
271
+ OpenAI
272
+ 5h 78% Rst 05:05
273
+ Weekly 73% Rst 03-12
274
+ ```
275
+
276
+ 2+ providers (even if each provider is single-window):
277
+
278
+ ```text
279
+ OpenAI
280
+ 5h 78% Rst 05:05
281
+ Copilot
282
+ Monthly 78% Rst 04-01
283
+ ```
284
+
285
+ 2+ providers mixed (multi-window + single-window):
286
+
287
+ ```text
288
+ OpenAI
289
+ 5h 78% Rst 05:05
290
+ Weekly 73% Rst 03-12
291
+ Copilot
292
+ Monthly 78% Rst 04-01
293
+ ```
294
+
295
+ Balance-style quota:
296
+
297
+ ```text
298
+ RC Balance $260
299
+ ```
300
+
301
+ Multi-detail quota (window + balance):
302
+
303
+ ```text
304
+ RC
305
+ Daily $88.9/$60 Exp 02-27
306
+ Balance $260
307
+ ```
308
+
309
+ Provider status (examples):
310
+
311
+ ```text
312
+ Anthropic unsupported
313
+ Copilot unavailable
314
+ OpenAI Remaining ?
315
+ ```
316
+
317
+ ### `sidebar.multilineTitle=false`
318
+
319
+ Quota is rendered inline as part of a single-line title:
320
+
321
+ ```text
322
+ <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | ...
323
+ ```
324
+
214
325
  `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.
215
326
 
216
327
  ## Debug logging
package/dist/format.js CHANGED
@@ -162,6 +162,56 @@ function alignPairs(pairs, indent = ' ') {
162
162
  return `${indent}${padEndCells(pair.label, labelWidth)} ${pair.value}`;
163
163
  });
164
164
  }
165
+ function compactQuotaInline(quota) {
166
+ const label = sanitizeLine(quotaDisplayLabel(quota));
167
+ if (quota.status !== 'ok')
168
+ return label;
169
+ if (quota.windows && quota.windows.length > 0) {
170
+ const first = quota.windows[0];
171
+ const showPercent = first.showPercent !== false;
172
+ const firstLabel = sanitizeLine(first.label || '');
173
+ const pct = first.remainingPercent === undefined
174
+ ? undefined
175
+ : `${Math.round(first.remainingPercent)}%`;
176
+ const summary = showPercent
177
+ ? [firstLabel, pct].filter(Boolean).join(' ')
178
+ : firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
179
+ const hasMore = quota.windows.length > 1 ||
180
+ (quota.balance !== undefined && !summary.includes('Balance '));
181
+ return `${label}${summary ? ` ${summary}` : ''}${hasMore ? '+' : ''}`;
182
+ }
183
+ if (quota.balance) {
184
+ return `${label} ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
185
+ }
186
+ if (quota.remainingPercent !== undefined) {
187
+ return `${label} ${Math.round(quota.remainingPercent)}%`;
188
+ }
189
+ return label;
190
+ }
191
+ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
192
+ const baseBudget = Math.min(16, Math.max(8, Math.floor(width * 0.35)));
193
+ const base = fitLine(baseTitle, baseBudget);
194
+ const segments = [
195
+ `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`,
196
+ ];
197
+ if (usage.cacheRead > 0) {
198
+ segments.push(`Cache Read ${sidebarNumber(usage.cacheRead)}`);
199
+ }
200
+ if (usage.cacheWrite > 0) {
201
+ segments.push(`Cache Write ${sidebarNumber(usage.cacheWrite)}`);
202
+ }
203
+ if (config.sidebar.showCost && usage.apiCost > 0) {
204
+ segments.push(formatApiCostLine(usage.apiCost));
205
+ }
206
+ if (config.sidebar.showQuota) {
207
+ const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
208
+ segments.push(...visibleQuotas.map(compactQuotaInline));
209
+ }
210
+ const detail = segments.filter(Boolean).join(' | ');
211
+ if (!detail)
212
+ return fitLine(baseTitle, width);
213
+ return fitLine(`${base} | ${detail}`, width);
214
+ }
165
215
  /**
166
216
  * Render sidebar title with multi-line token breakdown.
167
217
  *
@@ -175,8 +225,11 @@ function alignPairs(pairs, indent = ' ') {
175
225
  */
176
226
  export function renderSidebarTitle(baseTitle, usage, quotas, config) {
177
227
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
178
- const lines = [];
179
228
  const safeBaseTitle = stripAnsi(baseTitle || 'Session').split(/\r?\n/, 1)[0] || 'Session';
229
+ if (config.sidebar.multilineTitle !== true) {
230
+ return renderSingleLineTitle(safeBaseTitle, usage, quotas, config, width);
231
+ }
232
+ const lines = [];
180
233
  lines.push(fitLine(safeBaseTitle, width));
181
234
  lines.push('');
182
235
  // Input / Output line
@@ -195,6 +248,9 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
195
248
  // Quota lines (one provider per line for stable wrapping)
196
249
  if (config.sidebar.showQuota) {
197
250
  const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
251
+ // When multiple providers are visible, keep a consistent visual rhythm by
252
+ // always rendering each provider as a header line + indented detail line(s).
253
+ const forceWrappedProviders = visibleQuotas.length > 1;
198
254
  const labelWidth = visibleQuotas.reduce((max, item) => {
199
255
  const label = sanitizeLine(quotaDisplayLabel(item));
200
256
  return Math.max(max, stringCellWidth(label));
@@ -203,6 +259,7 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
203
259
  .flatMap((item) => compactQuotaWide(item, labelWidth, {
204
260
  width,
205
261
  wrapLines: config.sidebar.wrapQuotaLines,
262
+ forceWrapped: forceWrappedProviders,
206
263
  }))
207
264
  .filter((s) => Boolean(s));
208
265
  if (quotaItems.length > 0) {
@@ -237,19 +294,22 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
237
294
  const withLabel = (content) => `${labelPadded} ${content}`;
238
295
  const wrap = options?.wrapLines === true && (options?.width || 0) > 0;
239
296
  const width = options?.width || 0;
297
+ const forceWrapped = options?.forceWrapped === true;
240
298
  /** If inline version overflows, break into label-line + indented detail lines. */
241
299
  const maybeBreak = (inlineText, detailLines) => {
242
300
  const inline = withLabel(inlineText);
301
+ if (forceWrapped)
302
+ return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
243
303
  if (!wrap || stringCellWidth(inline) <= width)
244
304
  return [inline];
245
305
  return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
246
306
  };
247
307
  if (quota.status === 'error')
248
- return [withLabel('Remaining ?')];
308
+ return maybeBreak('Remaining ?', ['Remaining ?']);
249
309
  if (quota.status === 'unsupported')
250
- return [withLabel('unsupported')];
310
+ return maybeBreak('unsupported', ['unsupported']);
251
311
  if (quota.status === 'unavailable')
252
- return [withLabel('unavailable')];
312
+ return maybeBreak('unavailable', ['unavailable']);
253
313
  if (quota.status !== 'ok')
254
314
  return [];
255
315
  const balanceText = quota.balance
package/dist/index.js CHANGED
@@ -155,7 +155,6 @@ export async function QuotaSidebarPlugin(input) {
155
155
  markDirty,
156
156
  scheduleSave,
157
157
  renderSidebarTitle,
158
- quotaRuntime,
159
158
  getQuotaSnapshots,
160
159
  summarizeSessionUsageForDisplay,
161
160
  scheduleParentRefreshIfSafe,
@@ -2,11 +2,15 @@ import { isRecord, swallow } from '../../helpers.js';
2
2
  import { asNumber, configuredProviderEnabled, fetchWithTimeout, normalizePercent, toIso, } from '../common.js';
3
3
  async function fetchCopilotQuota(ctx) {
4
4
  const checkedAt = Date.now();
5
+ const sourceProviderID = typeof ctx.sourceProviderID === 'string' && ctx.sourceProviderID
6
+ ? ctx.sourceProviderID
7
+ : ctx.providerID;
8
+ const enterprise = sourceProviderID.startsWith('github-copilot-enterprise');
5
9
  const base = {
6
- providerID: ctx.providerID,
10
+ providerID: sourceProviderID,
7
11
  adapterID: 'github-copilot',
8
- label: 'GitHub Copilot',
9
- shortLabel: 'Copilot',
12
+ label: enterprise ? 'GitHub Copilot Enterprise' : 'GitHub Copilot',
13
+ shortLabel: enterprise ? 'Copilot Ent' : 'Copilot',
10
14
  sortOrder: 20,
11
15
  };
12
16
  if (!ctx.auth) {
package/dist/storage.js CHANGED
@@ -11,6 +11,7 @@ export const defaultConfig = {
11
11
  sidebar: {
12
12
  enabled: true,
13
13
  width: 36,
14
+ multilineTitle: true,
14
15
  showCost: true,
15
16
  showQuota: true,
16
17
  wrapQuotaLines: true,
@@ -64,6 +65,7 @@ export async function loadConfig(paths) {
64
65
  sidebar: {
65
66
  enabled: asBoolean(sidebar.enabled, base.sidebar.enabled),
66
67
  width: Math.max(20, Math.min(60, asNumber(sidebar.width, base.sidebar.width))),
68
+ multilineTitle: asBoolean(sidebar.multilineTitle, base.sidebar.multilineTitle ?? true),
67
69
  showCost: asBoolean(sidebar.showCost, base.sidebar.showCost),
68
70
  showQuota: asBoolean(sidebar.showQuota, base.sidebar.showQuota),
69
71
  wrapQuotaLines: asBoolean(sidebar.wrapQuotaLines, base.sidebar.wrapQuotaLines),
package/dist/title.js CHANGED
@@ -1,6 +1,51 @@
1
+ function sanitizeTitleFragment(value) {
2
+ return stripAnsi(value)
3
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
4
+ .trimEnd();
5
+ }
6
+ function isStrongDecoratedDetail(line) {
7
+ if (!line)
8
+ return false;
9
+ if (/^Input\s+\S+\s+Output(?:\s+\S+)?/.test(line))
10
+ return true;
11
+ if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
12
+ return true;
13
+ if (/^\$\S+\s+as API cost\b/.test(line))
14
+ return true;
15
+ // Single-line compact mode compatibility.
16
+ if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
17
+ return true;
18
+ if (/^C(?:ache\s*)?R(?:ead)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
19
+ return true;
20
+ if (/^C(?:ache\s*)?W(?:rite)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
21
+ return true;
22
+ return false;
23
+ }
24
+ function isQuotaLikeProviderDetail(line) {
25
+ if (!line)
26
+ return false;
27
+ if (!/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
28
+ return false;
29
+ return /\b(Rst|Exp\+?|Balance|Remaining)\b|\d{1,3}%/.test(line);
30
+ }
31
+ function decoratedSingleLineBase(line) {
32
+ const parts = sanitizeTitleFragment(line)
33
+ .split(/\s*\|\s*/)
34
+ .map((part) => part.trim());
35
+ if (parts.length < 2)
36
+ return undefined;
37
+ const details = parts.slice(1);
38
+ if (!details.some((detail) => isStrongDecoratedDetail(detail))) {
39
+ return undefined;
40
+ }
41
+ return parts[0] || 'Session';
42
+ }
1
43
  export function normalizeBaseTitle(title) {
2
44
  const firstLine = stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
3
- return firstLine.replace(/[\x00-\x1F\x7F-\x9F]/g, ' ').trimEnd() || 'Session';
45
+ const decoratedBase = decoratedSingleLineBase(firstLine);
46
+ if (decoratedBase)
47
+ return decoratedBase;
48
+ return sanitizeTitleFragment(firstLine) || 'Session';
4
49
  }
5
50
  export function stripAnsi(value) {
6
51
  // Remove terminal escape sequences. Sidebar titles must be plain text.
@@ -40,20 +85,12 @@ export function canonicalizeTitleForCompare(value) {
40
85
  */
41
86
  export function looksDecorated(title) {
42
87
  const lines = stripAnsi(title).split(/\r?\n/);
43
- if (lines.length < 2)
44
- return false;
45
- const detail = lines.slice(1).map((line) => line.trim());
46
- return detail.some((line) => {
47
- if (!line)
48
- return false;
49
- if (/^Input\s+\S+\s+Output\s+\S+/.test(line))
50
- return true;
51
- if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
52
- return true;
53
- if (/^\$\S+\s+as API cost/.test(line))
54
- return true;
55
- if (/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
56
- return true;
57
- return false;
58
- });
88
+ if (lines.length < 2) {
89
+ return Boolean(decoratedSingleLineBase(lines[0] || ''));
90
+ }
91
+ const detail = lines
92
+ .slice(1)
93
+ .map((line) => sanitizeTitleFragment(line).trim());
94
+ return (detail.some((line) => isStrongDecoratedDetail(line)) ||
95
+ detail.some((line) => isQuotaLikeProviderDetail(line)));
59
96
  }
@@ -10,9 +10,6 @@ export declare function createTitleApplicator(deps: {
10
10
  markDirty: (dateKey: string | undefined) => void;
11
11
  scheduleSave: () => void;
12
12
  renderSidebarTitle: (baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig) => string;
13
- quotaRuntime: {
14
- normalizeProviderID: (providerID: string) => string;
15
- };
16
13
  getQuotaSnapshots: (providerIDs: string[], options?: {
17
14
  allowDefault?: boolean;
18
15
  }) => Promise<QuotaSnapshot[]>;
@@ -56,7 +56,7 @@ export function createTitleApplicator(deps) {
56
56
  }
57
57
  }
58
58
  const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
59
- const quotaProviders = Array.from(new Set(Object.keys(usage.providers).map((id) => deps.quotaRuntime.normalizeProviderID(id))));
59
+ const quotaProviders = Array.from(new Set(Object.keys(usage.providers)));
60
60
  const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
61
61
  ? await deps.getQuotaSnapshots(quotaProviders)
62
62
  : [];
package/dist/types.d.ts CHANGED
@@ -97,6 +97,12 @@ export type QuotaSidebarConfig = {
97
97
  sidebar: {
98
98
  enabled: boolean;
99
99
  width: number;
100
+ /**
101
+ * When true, render multi-line decorated session titles.
102
+ * Enabled by default for clearer token/quota layout in sidebar.
103
+ * Set false to keep a compact single-line title.
104
+ */
105
+ multilineTitle?: boolean;
100
106
  showCost: boolean;
101
107
  showQuota: boolean;
102
108
  /** When true, wrap long quota lines and indent continuations. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2,8 +2,10 @@
2
2
  "sidebar": {
3
3
  "enabled": true,
4
4
  "width": 36,
5
+ "multilineTitle": true,
5
6
  "showCost": true,
6
7
  "showQuota": true,
8
+ "wrapQuotaLines": true,
7
9
  "includeChildren": true,
8
10
  "childrenMaxDepth": 6,
9
11
  "childrenMaxSessions": 128,