@leo000001/opencode-quota-sidebar 3.0.0 → 3.0.1

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/dist/format.d.ts CHANGED
@@ -27,6 +27,10 @@ export declare function renderSidebarUsageLines(usage: UsageSummary, config: Quo
27
27
  showCost?: boolean;
28
28
  }): string[];
29
29
  export declare function renderSidebarQuotaLines(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): string[];
30
+ export declare function renderSidebarQuotaLineGroups(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): {
31
+ quota: QuotaSnapshot;
32
+ lines: string[];
33
+ }[];
30
34
  export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
31
35
  showCost?: boolean;
32
36
  }): string;
package/dist/format.js CHANGED
@@ -599,6 +599,9 @@ export function renderSidebarUsageLines(usage, config, options) {
599
599
  }).map((line) => fitLine(line, width));
600
600
  }
601
601
  export function renderSidebarQuotaLines(quotas, config) {
602
+ return renderSidebarQuotaLineGroups(quotas, config).flatMap((group) => group.lines);
603
+ }
604
+ export function renderSidebarQuotaLineGroups(quotas, config) {
602
605
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
603
606
  const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
604
607
  const labelWidth = visibleQuotas.reduce((max, item) => {
@@ -606,14 +609,18 @@ export function renderSidebarQuotaLines(quotas, config) {
606
609
  return Math.max(max, stringCellWidth(label));
607
610
  }, 0);
608
611
  return visibleQuotas
609
- .flatMap((item) => compactQuotaWide(item, labelWidth, {
610
- width,
611
- wrapLines: config.sidebar.wrapQuotaLines,
612
- forceWrapped: false,
613
- compactDetails: true,
612
+ .map((item) => ({
613
+ quota: item,
614
+ lines: 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)),
614
622
  }))
615
- .filter((line) => Boolean(line))
616
- .map((line) => fitLine(line, width));
623
+ .filter((group) => group.lines.length > 0);
617
624
  }
618
625
  /**
619
626
  * Multi-window quota format for sidebar.
package/dist/tui.tsx CHANGED
@@ -6,11 +6,15 @@ import type {
6
6
  } from '@opencode-ai/plugin/tui'
7
7
  import { createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
8
8
 
9
+ import { fitLine, renderSidebarUsageLines } from './format.js'
9
10
  import {
10
- fitLine,
11
- renderSidebarQuotaLines,
12
- renderSidebarUsageLines,
13
- } from './format.js'
11
+ fallbackQuotaGroupsFromTitle,
12
+ quotaGroupsAreCollapsible,
13
+ quotaGroupsSummary,
14
+ quotaGroupsUseBullets,
15
+ renderSidebarQuotaGroups,
16
+ type SidebarQuotaGroup,
17
+ } from './tui_helpers.js'
14
18
  import {
15
19
  loadConfig,
16
20
  loadState,
@@ -31,7 +35,7 @@ type SidebarPanelData = {
31
35
  enabled: boolean
32
36
  width: number
33
37
  usageLines: string[]
34
- quotaLines: string[]
38
+ quotaGroups: SidebarQuotaGroup[]
35
39
  compactTitle?: string
36
40
  }
37
41
 
@@ -96,7 +100,7 @@ async function loadSidebarPanel(
96
100
  enabled,
97
101
  width,
98
102
  usageLines: [],
99
- quotaLines: [],
103
+ quotaGroups: [],
100
104
  compactTitle: session?.lastAppliedTitle,
101
105
  }
102
106
  }
@@ -104,7 +108,7 @@ async function loadSidebarPanel(
104
108
  const usageLines = usage
105
109
  ? renderSidebarUsageLines(usage, panelConfig(config))
106
110
  : []
107
- const quotaLines = renderSidebarQuotaLines(
111
+ const quotaGroups = renderSidebarQuotaGroups(
108
112
  session?.sidebarPanel?.quotas || [],
109
113
  panelConfig(config),
110
114
  )
@@ -113,7 +117,7 @@ async function loadSidebarPanel(
113
117
  enabled,
114
118
  width,
115
119
  usageLines,
116
- quotaLines,
120
+ quotaGroups,
117
121
  compactTitle,
118
122
  }
119
123
  }
@@ -193,20 +197,83 @@ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
193
197
  return panel
194
198
  }
195
199
 
196
- function sectionHeading(api: TuiPluginApi, value: string) {
197
- return <text fg={api.theme.current.textMuted}>{value}</text>
200
+ function SectionHeading(props: {
201
+ api: TuiPluginApi
202
+ value: string
203
+ collapsible?: boolean
204
+ open?: boolean
205
+ summary?: string
206
+ onToggle?: () => void
207
+ }) {
208
+ const clickable = () => props.collapsible === true && props.onToggle
209
+
210
+ return (
211
+ <box
212
+ flexDirection="row"
213
+ gap={1}
214
+ onMouseDown={() => {
215
+ if (!clickable()) return
216
+ props.onToggle?.()
217
+ }}
218
+ >
219
+ <Show when={props.collapsible}>
220
+ <text fg={props.api.theme.current.text}>{props.open ? '▼' : '▶'}</text>
221
+ </Show>
222
+ <text fg={props.api.theme.current.text}>
223
+ <b>{props.value}</b>
224
+ <Show when={props.summary}>
225
+ <span style={{ fg: props.api.theme.current.textMuted }}>
226
+ {' '}
227
+ {props.summary}
228
+ </span>
229
+ </Show>
230
+ </text>
231
+ </box>
232
+ )
198
233
  }
199
234
 
200
- function fallbackQuotaLinesFromTitle(title: string, width: number) {
201
- const parts = (title || '')
202
- .split(' | ')
203
- .map((part) => part.trim())
204
- .filter(Boolean)
205
- if (parts.length <= 1) return [] as string[]
206
- return parts
207
- .slice(1)
208
- .filter((part) => !/^Cd\d/.test(part) && !/^Est\b/.test(part))
209
- .map((part) => fitLine(part, width))
235
+ function quotaToneColor(api: TuiPluginApi, tone: SidebarQuotaGroup['tone']) {
236
+ const theme = api.theme.current
237
+ if (tone === 'success') return theme.success
238
+ if (tone === 'warning') return theme.warning
239
+ if (tone === 'error') return theme.error
240
+ return theme.textMuted
241
+ }
242
+
243
+ function QuotaGroupBlock(props: {
244
+ api: TuiPluginApi
245
+ group: SidebarQuotaGroup
246
+ bullet: boolean
247
+ }) {
248
+ const content = (
249
+ <box gap={0}>
250
+ <text>
251
+ <span style={{ fg: props.api.theme.current.text }}>
252
+ {props.group.shortLabel}
253
+ </span>
254
+ <Show when={props.group.detail}>
255
+ <span style={{ fg: props.api.theme.current.textMuted }}>
256
+ {' '}
257
+ {props.group.detail}
258
+ </span>
259
+ </Show>
260
+ </text>
261
+ <For each={props.group.continuationLines}>
262
+ {(line) => <text fg={props.api.theme.current.textMuted}>{line}</text>}
263
+ </For>
264
+ </box>
265
+ )
266
+
267
+ return (
268
+ <Show when={props.bullet} fallback={content}>
269
+ <box flexDirection="row" gap={1}>
270
+ <text flexShrink={0} fg={quotaToneColor(props.api, props.group.tone)}>
271
+
272
+ </text>
273
+ {content}
274
+ </box>
275
+ </Show>
276
+ )
210
277
  }
211
278
 
212
279
  function fallbackUsageCostLineFromTitle(title: string, width: number) {
@@ -220,6 +287,7 @@ function fallbackUsageCostLineFromTitle(title: string, width: number) {
220
287
 
221
288
  function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
222
289
  const panel = useSidebarPanelData(props.api, () => props.sessionID)
290
+ const [quotaOpen, setQuotaOpen] = createSignal(true)
223
291
  const width = createMemo(
224
292
  () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
225
293
  )
@@ -234,22 +302,32 @@ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
234
302
  const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
235
303
  return costLine ? [...liveLines, costLine] : liveLines
236
304
  })
237
- const quotaLines = createMemo(() => {
238
- const liveLines = panel()?.quotaLines || []
239
- if (liveLines.length > 0) return liveLines
240
- return fallbackQuotaLinesFromTitle(compactTitle(), width())
305
+ const quotaGroups = createMemo(() => {
306
+ const liveGroups = panel()?.quotaGroups || []
307
+ if (liveGroups.length > 0) return liveGroups
308
+ return fallbackQuotaGroupsFromTitle(compactTitle(), width())
241
309
  })
242
310
  const hasUsage = createMemo(() => usageLines().length > 0)
243
- const hasQuota = createMemo(() => quotaLines().length > 0)
311
+ const hasQuota = createMemo(() => quotaGroups().length > 0)
312
+ const quotaBullets = createMemo(() => quotaGroupsUseBullets(quotaGroups()))
313
+ const quotaCollapsible = createMemo(() =>
314
+ quotaGroupsAreCollapsible(quotaGroups()),
315
+ )
316
+ const quotaSummary = createMemo(() => {
317
+ if (!quotaCollapsible() || quotaOpen()) return undefined
318
+ return quotaGroupsSummary(quotaGroups())
319
+ })
244
320
 
245
321
  return (
246
322
  <box gap={0}>
247
323
  <Show when={hasUsage()}>
248
324
  <box gap={0}>
249
- {sectionHeading(props.api, 'USAGE')}
325
+ <SectionHeading api={props.api} value="Usage" />
250
326
  <box gap={0}>
251
327
  <For each={usageLines()}>
252
- {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
328
+ {(line) => (
329
+ <text fg={props.api.theme.current.textMuted}>{line}</text>
330
+ )}
253
331
  </For>
254
332
  </box>
255
333
  </box>
@@ -257,12 +335,27 @@ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
257
335
 
258
336
  <Show when={hasQuota()}>
259
337
  <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
260
- {sectionHeading(props.api, 'QUOTA')}
261
- <box gap={0}>
262
- <For each={quotaLines()}>
263
- {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
264
- </For>
265
- </box>
338
+ <SectionHeading
339
+ api={props.api}
340
+ value="Quota"
341
+ collapsible={quotaCollapsible()}
342
+ open={quotaOpen()}
343
+ summary={quotaSummary()}
344
+ onToggle={() => setQuotaOpen((value) => !value)}
345
+ />
346
+ <Show when={!quotaCollapsible() || quotaOpen()}>
347
+ <box gap={0}>
348
+ <For each={quotaGroups()}>
349
+ {(group) => (
350
+ <QuotaGroupBlock
351
+ api={props.api}
352
+ group={group}
353
+ bullet={quotaBullets()}
354
+ />
355
+ )}
356
+ </For>
357
+ </box>
358
+ </Show>
266
359
  </box>
267
360
  </Show>
268
361
  </box>
@@ -292,10 +385,13 @@ function SidebarTitleView(props: {
292
385
 
293
386
  return (
294
387
  <box gap={0} paddingRight={1}>
295
- {sectionHeading(props.api, 'TITLE')}
296
388
  <box gap={0}>
297
389
  <For each={titleLines()}>
298
- {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
390
+ {(line) => (
391
+ <text fg={props.api.theme.current.text}>
392
+ <b>{line}</b>
393
+ </text>
394
+ )}
299
395
  </For>
300
396
  <Show when={shareLine()}>
301
397
  <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
@@ -0,0 +1,15 @@
1
+ import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
2
+ export type SidebarQuotaTone = 'success' | 'warning' | 'error' | 'muted';
3
+ export type SidebarQuotaGroup = {
4
+ providerID: string;
5
+ status: QuotaSnapshot['status'];
6
+ tone: SidebarQuotaTone;
7
+ shortLabel: string;
8
+ detail: string;
9
+ continuationLines: string[];
10
+ };
11
+ export declare function renderSidebarQuotaGroups(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): SidebarQuotaGroup[];
12
+ export declare function fallbackQuotaGroupsFromTitle(title: string, width: number): SidebarQuotaGroup[];
13
+ export declare function quotaGroupsUseBullets(groups: SidebarQuotaGroup[]): boolean;
14
+ export declare function quotaGroupsAreCollapsible(groups: SidebarQuotaGroup[]): boolean;
15
+ export declare function quotaGroupsSummary(groups: SidebarQuotaGroup[]): string | undefined;
@@ -0,0 +1,141 @@
1
+ import { fitLine, renderSidebarQuotaLineGroups } from './format.js';
2
+ import { collapseQuotaSnapshots } from './quota_render.js';
3
+ const VISIBLE_QUOTA_STATUSES = new Set([
4
+ 'ok',
5
+ 'error',
6
+ 'unsupported',
7
+ 'unavailable',
8
+ ]);
9
+ function parseQuotaLineParts(lines) {
10
+ const firstLine = lines[0]?.trimStart() || '';
11
+ const match = /^(\S+)(?:\s+(.*))?$/.exec(firstLine);
12
+ const shortLabel = match?.[1] || firstLine || 'Quota';
13
+ const detail = match?.[2] || '';
14
+ const continuationLines = lines
15
+ .slice(1)
16
+ .map((line) => line.trimEnd())
17
+ .filter((line) => Boolean(line.trim()));
18
+ return {
19
+ shortLabel,
20
+ detail,
21
+ continuationLines,
22
+ };
23
+ }
24
+ function quotaPercents(quota) {
25
+ const values = [];
26
+ if (quota.remainingPercent !== undefined &&
27
+ Number.isFinite(quota.remainingPercent)) {
28
+ values.push(quota.remainingPercent);
29
+ }
30
+ for (const window of quota.windows || []) {
31
+ if (window.remainingPercent !== undefined &&
32
+ Number.isFinite(window.remainingPercent)) {
33
+ values.push(window.remainingPercent);
34
+ }
35
+ }
36
+ return values;
37
+ }
38
+ function quotaTone(quota) {
39
+ if (quota.status === 'error')
40
+ return 'error';
41
+ if (quota.status === 'unsupported' || quota.status === 'unavailable') {
42
+ return 'muted';
43
+ }
44
+ if (quota.status !== 'ok')
45
+ return 'muted';
46
+ const percents = quotaPercents(quota);
47
+ if (percents.length === 0) {
48
+ if (quota.balance && Number.isFinite(quota.balance.amount)) {
49
+ if (quota.balance.amount < 0)
50
+ return 'error';
51
+ return 'muted';
52
+ }
53
+ return 'muted';
54
+ }
55
+ const remaining = Math.min(...percents);
56
+ if (remaining <= 5)
57
+ return 'error';
58
+ if (remaining <= 20)
59
+ return 'warning';
60
+ return 'success';
61
+ }
62
+ function fallbackQuotaTone(detail) {
63
+ const safe = detail.trim();
64
+ if (!safe)
65
+ return 'muted';
66
+ if (/\b(?:unsupported|unavailable)\b/i.test(safe))
67
+ return 'muted';
68
+ if (/\berror\b/i.test(safe) || /^\?$/.test(safe))
69
+ return 'error';
70
+ if (/\bB-/.test(safe))
71
+ return 'error';
72
+ const percents = [...safe.matchAll(/\b(?:\d+[hdw]|[DWM]|S7d)(\d{1,3})\b/gi)]
73
+ .map((match) => Number(match[1]))
74
+ .filter((value) => Number.isFinite(value));
75
+ if (percents.length === 0)
76
+ return 'muted';
77
+ const remaining = Math.min(...percents);
78
+ if (remaining <= 5)
79
+ return 'error';
80
+ if (remaining <= 20)
81
+ return 'warning';
82
+ return 'success';
83
+ }
84
+ export function renderSidebarQuotaGroups(quotas, config) {
85
+ const visibleQuotaCount = collapseQuotaSnapshots(quotas).filter((quota) => VISIBLE_QUOTA_STATUSES.has(quota.status)).length;
86
+ const renderConfig = visibleQuotaCount > 1
87
+ ? {
88
+ ...config,
89
+ sidebar: {
90
+ ...config.sidebar,
91
+ width: Math.max(8, config.sidebar.width - 2),
92
+ },
93
+ }
94
+ : config;
95
+ return renderSidebarQuotaLineGroups(quotas, renderConfig).map((group) => {
96
+ const parsed = parseQuotaLineParts(group.lines);
97
+ return {
98
+ providerID: group.quota.providerID,
99
+ status: group.quota.status,
100
+ tone: quotaTone(group.quota),
101
+ shortLabel: parsed.shortLabel,
102
+ detail: parsed.detail,
103
+ continuationLines: parsed.continuationLines,
104
+ };
105
+ });
106
+ }
107
+ export function fallbackQuotaGroupsFromTitle(title, width) {
108
+ const parts = (title || '')
109
+ .split(' | ')
110
+ .map((part) => part.trim())
111
+ .filter(Boolean);
112
+ const quotaParts = parts
113
+ .slice(1)
114
+ .filter((part) => !/^Cd\d/.test(part) && !/^Est\b/.test(part));
115
+ if (quotaParts.length === 0)
116
+ return [];
117
+ const contentWidth = quotaParts.length > 1 ? Math.max(1, width - 2) : width;
118
+ return quotaParts.map((part, index) => {
119
+ const line = fitLine(part, contentWidth);
120
+ const parsed = parseQuotaLineParts([line]);
121
+ return {
122
+ providerID: `fallback:${index}`,
123
+ status: 'ok',
124
+ tone: fallbackQuotaTone(parsed.detail),
125
+ shortLabel: parsed.shortLabel,
126
+ detail: parsed.detail,
127
+ continuationLines: parsed.continuationLines,
128
+ };
129
+ });
130
+ }
131
+ export function quotaGroupsUseBullets(groups) {
132
+ return groups.length > 1;
133
+ }
134
+ export function quotaGroupsAreCollapsible(groups) {
135
+ return groups.length > 2;
136
+ }
137
+ export function quotaGroupsSummary(groups) {
138
+ if (groups.length === 0)
139
+ return undefined;
140
+ return `(${groups.length})`;
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "OpenCode plugin that shows quota and token usage in TUI sidebar panels and compact session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",