@leo000001/opencode-quota-sidebar 3.0.0 → 3.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +14 -2
  2. package/CONTRIBUTING.md +4 -1
  3. package/README.md +210 -514
  4. package/README.zh-CN.md +337 -0
  5. package/SECURITY.md +2 -2
  6. package/assets/OpenCode-Quota-Sidebar.png +0 -0
  7. package/dist/cost.d.ts +3 -3
  8. package/dist/cost.js +258 -169
  9. package/dist/format.d.ts +4 -0
  10. package/dist/format.js +24 -10
  11. package/dist/providers/common.d.ts +6 -0
  12. package/dist/providers/common.js +32 -12
  13. package/dist/providers/core/anthropic.d.ts +1 -1
  14. package/dist/providers/core/anthropic.js +43 -39
  15. package/dist/providers/core/kimi_for_coding.d.ts +1 -1
  16. package/dist/providers/core/kimi_for_coding.js +44 -64
  17. package/dist/providers/core/minimax_cn_coding_plan.d.ts +2 -0
  18. package/dist/providers/core/minimax_cn_coding_plan.js +214 -0
  19. package/dist/providers/core/zhipu_coding_plan.d.ts +1 -1
  20. package/dist/providers/core/zhipu_coding_plan.js +41 -61
  21. package/dist/providers/index.d.ts +3 -3
  22. package/dist/providers/index.js +5 -5
  23. package/dist/providers/third_party/rightcode.d.ts +1 -1
  24. package/dist/providers/third_party/rightcode.js +41 -61
  25. package/dist/providers/third_party/xyai.d.ts +2 -0
  26. package/dist/providers/third_party/{xyai_vibe.js → xyai.js} +113 -79
  27. package/dist/quota.d.ts +2 -2
  28. package/dist/quota.js +24 -18
  29. package/dist/quota_render.d.ts +1 -1
  30. package/dist/quota_render.js +23 -17
  31. package/dist/storage_parse.js +1 -0
  32. package/dist/title.js +7 -7
  33. package/dist/title_apply.js +18 -1
  34. package/dist/tui.tsx +133 -36
  35. package/dist/tui_helpers.d.ts +16 -0
  36. package/dist/tui_helpers.js +146 -0
  37. package/dist/types.d.ts +2 -0
  38. package/package.json +9 -2
  39. package/quota-sidebar.config.example.json +45 -45
  40. package/dist/providers/third_party/buzz.d.ts +0 -2
  41. package/dist/providers/third_party/buzz.js +0 -156
  42. package/dist/providers/third_party/xyai_vibe.d.ts +0 -2
package/dist/quota.js CHANGED
@@ -1,14 +1,14 @@
1
- import fs from 'node:fs/promises';
2
- import { isRecord, swallow } from './helpers.js';
3
- import { createDefaultProviderRegistry } from './providers/index.js';
4
- import { sanitizeBaseURL } from './providers/common.js';
1
+ import fs from "node:fs/promises";
2
+ import { isRecord, swallow } from "./helpers.js";
3
+ import { createDefaultProviderRegistry } from "./providers/index.js";
4
+ import { sanitizeBaseURL } from "./providers/common.js";
5
5
  function resolveContext(providerID, providerOptions) {
6
6
  return { providerID, providerOptions };
7
7
  }
8
8
  function authCandidates(providerID, normalizedProviderID, adapterID) {
9
9
  const candidates = new Set([providerID]);
10
- if (adapterID === 'github-copilot') {
11
- candidates.add('github-copilot-enterprise');
10
+ if (adapterID === "github-copilot") {
11
+ candidates.add("github-copilot-enterprise");
12
12
  }
13
13
  candidates.add(normalizedProviderID);
14
14
  candidates.add(adapterID);
@@ -34,11 +34,12 @@ export function quotaSort(left, right) {
34
34
  export function listDefaultQuotaProviderIDs() {
35
35
  // Keep default report behavior stable for built-in subscription providers.
36
36
  return [
37
- 'openai',
38
- 'kimi-for-coding',
39
- 'zhipuai-coding-plan',
40
- 'github-copilot',
41
- 'anthropic',
37
+ "openai",
38
+ "kimi-for-coding",
39
+ "zhipuai-coding-plan",
40
+ "minimax-cn-coding-plan",
41
+ "github-copilot",
42
+ "anthropic",
42
43
  ];
43
44
  }
44
45
  export function createQuotaRuntime() {
@@ -58,10 +59,15 @@ export function createQuotaRuntime() {
58
59
  if (adapter?.id && normalizedProviderID !== providerID) {
59
60
  keyBase = `${adapter.id}:${providerID}`;
60
61
  }
61
- // RightCode variants intentionally keep provider-specific labels (RC-openai,
62
- // RC-foo). Preserve that identity in cache keys so snapshots don't collide.
63
- if (adapter?.id === 'rightcode' &&
64
- normalizedProviderID.startsWith('rightcode-')) {
62
+ // Some third-party adapters intentionally preserve provider-specific labels
63
+ // (for example RC-openai or an XYAI alias) even when they share one adapter.
64
+ // Keep the original provider identity in cache keys so same-host aliases with
65
+ // different auth/config entries do not overwrite each other.
66
+ if ((adapter?.id === "rightcode" &&
67
+ normalizedProviderID.startsWith("rightcode-")) ||
68
+ (adapter?.id === "xyai" &&
69
+ providerID !== "xyai" &&
70
+ providerID !== "xyai-vibe")) {
65
71
  keyBase = normalizedProviderID;
66
72
  }
67
73
  return baseURL ? `${keyBase}@${baseURL}` : keyBase;
@@ -110,16 +116,16 @@ export function quotaCacheKey(providerID, providerOptions) {
110
116
  }
111
117
  export async function loadAuthMap(authPath) {
112
118
  const parsed = await fs
113
- .readFile(authPath, 'utf8')
119
+ .readFile(authPath, "utf8")
114
120
  .then((value) => JSON.parse(value))
115
- .catch(swallow('loadAuthMap'));
121
+ .catch(swallow("loadAuthMap"));
116
122
  if (!isRecord(parsed))
117
123
  return {};
118
124
  return Object.entries(parsed).reduce((acc, [key, value]) => {
119
125
  if (!isRecord(value))
120
126
  return acc;
121
127
  const type = value.type;
122
- if (type !== 'oauth' && type !== 'api' && type !== 'wellknown')
128
+ if (type !== "oauth" && type !== "api" && type !== "wellknown")
123
129
  return acc;
124
130
  acc[key] = value;
125
131
  return acc;
@@ -1,4 +1,4 @@
1
- import type { QuotaSnapshot } from './types.js';
1
+ import type { QuotaSnapshot } from "./types.js";
2
2
  export declare function canonicalProviderID(providerID: string): string;
3
3
  export declare function displayShortLabel(providerID: string): string;
4
4
  export declare function quotaDisplayLabel(quota: QuotaSnapshot): string;
@@ -1,16 +1,22 @@
1
1
  const PROVIDER_SHORT_LABELS = {
2
- openai: 'OpenAI',
3
- 'kimi-for-coding': 'Kimi',
4
- 'zhipuai-coding-plan': 'Zhipu',
5
- 'github-copilot': 'Copilot',
6
- anthropic: 'Anthropic',
7
- rightcode: 'RC',
2
+ openai: "OpenAI",
3
+ "kimi-for-coding": "Kimi",
4
+ "zhipuai-coding-plan": "Zhipu",
5
+ "minimax-cn-coding-plan": "MiniMax",
6
+ "github-copilot": "Copilot",
7
+ anthropic: "Anthropic",
8
+ rightcode: "RC",
9
+ xyai: "XYAI",
8
10
  };
9
11
  export function canonicalProviderID(providerID) {
10
- if (providerID.startsWith('github-copilot'))
11
- return 'github-copilot';
12
- if (providerID === 'zhipuai-coding-plan')
13
- return 'zhipuai-coding-plan';
12
+ if (providerID.startsWith("github-copilot"))
13
+ return "github-copilot";
14
+ if (providerID === "zhipuai-coding-plan")
15
+ return "zhipuai-coding-plan";
16
+ if (providerID === "minimax-cn-coding-plan")
17
+ return "minimax-cn-coding-plan";
18
+ if (providerID === "xyai-vibe")
19
+ return "xyai";
14
20
  return providerID;
15
21
  }
16
22
  export function displayShortLabel(providerID) {
@@ -18,8 +24,8 @@ export function displayShortLabel(providerID) {
18
24
  const direct = PROVIDER_SHORT_LABELS[canonical];
19
25
  if (direct)
20
26
  return direct;
21
- if (canonical.startsWith('rightcode-')) {
22
- return `RC-${canonical.slice('rightcode-'.length)}`;
27
+ if (canonical.startsWith("rightcode-")) {
28
+ return `RC-${canonical.slice("rightcode-".length)}`;
23
29
  }
24
30
  return providerID;
25
31
  }
@@ -34,13 +40,13 @@ export function quotaDisplayLabel(quota) {
34
40
  return displayShortLabel(quota.providerID);
35
41
  }
36
42
  function quotaKey(quota) {
37
- if (quota.adapterID === 'rightcode')
43
+ if (quota.adapterID === "rightcode")
38
44
  return `rightcode:${quota.providerID}`;
39
45
  return `${quota.adapterID || quota.providerID}:${quota.providerID}`;
40
46
  }
41
47
  function quotaScore(quota) {
42
48
  let score = 0;
43
- if (quota.status === 'ok')
49
+ if (quota.status === "ok")
44
50
  score += 10;
45
51
  if (quota.windows && quota.windows.length > 0) {
46
52
  score += 5 + quota.windows.length;
@@ -53,13 +59,13 @@ function quotaScore(quota) {
53
59
  }
54
60
  export function collapseQuotaSnapshots(quotas) {
55
61
  const grouped = new Map();
56
- const hasRightCodeBase = quotas.some((quota) => quota.adapterID === 'rightcode' && quotaDisplayLabel(quota) === 'RC');
62
+ const hasRightCodeBase = quotas.some((quota) => quota.adapterID === "rightcode" && quotaDisplayLabel(quota) === "RC");
57
63
  for (const quota of quotas) {
58
64
  // If both RC (balance) and RC-variant (subscription) exist,
59
65
  // treat balance as owned by RC.
60
66
  const normalizedQuota = hasRightCodeBase &&
61
- quota.adapterID === 'rightcode' &&
62
- quotaDisplayLabel(quota).startsWith('RC-')
67
+ quota.adapterID === "rightcode" &&
68
+ quotaDisplayLabel(quota).startsWith("RC-")
63
69
  ? { ...quota, balance: undefined }
64
70
  : quota;
65
71
  const key = quotaKey(normalizedQuota);
@@ -192,6 +192,7 @@ function parseSidebarPanel(value) {
192
192
  version: 1,
193
193
  updatedAt,
194
194
  usage: parseCachedUsage(value.usage),
195
+ panelQuotas: parseQuotaSnapshots(value.panelQuotas),
195
196
  quotas: parseQuotaSnapshots(value.quotas),
196
197
  };
197
198
  }
package/dist/title.js CHANGED
@@ -57,28 +57,28 @@ function isCoreDecoratedDetail(line) {
57
57
  function isQuotaDecoratedDetail(line) {
58
58
  if (!line)
59
59
  return false;
60
- if (/^(OAI|Cop|Ant|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)(?:\s+(?:\?|unsupported|unavailable|error|(?:\d+h|D|W|M)\d{1,3}|D[\d.,]+\/[\d.,]+|B(?:[¥$-])?[\d.,]+))+$/i.test(line)) {
60
+ if (/^(OAI|Cop|Ant|Kimi|XYAI|RC(?:-[^\s]+)?)(?:\s+(?:\?|unsupported|unavailable|error|(?:\d+h|D|W|M)\d{1,3}|D[\d.,]+\/[\d.,]+|B(?:[¥$-])?[\d.,]+))+$/i.test(line)) {
61
61
  return true;
62
62
  }
63
- if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)\s*$/.test(line)) {
63
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|RC(?:-[^\s]+)?)\s*$/.test(line)) {
64
64
  return true;
65
65
  }
66
66
  if (/^(?:(?:Daily\s+\$[\d.,]+\/\$[\d.,]+|\$[\d.,]+\/\$[\d.,]+)(?:\s+(?:Rst|Exp\+?)\s+[-:\d]+)?|(?:\d+[hdw]|Weekly|Monthly)\s+\d{1,3}%(?:\s+Rst\s+[-:\d]+)?|Balance\s+\$[\d.,]+|Remaining\s+\?|(?:error|unsupported|unavailable))$/.test(line)) {
67
67
  return true;
68
68
  }
69
- if (/^(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|S7d\d{1,3})(?:\s+(?:R|E\+?)\d[\d:.-]*)?$/.test(line)) {
69
+ if (/^(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|(?:S7d|O7d|OA7d|Co7d)\d{1,3})(?:\s+(?:R|E\+?)\d[\d:.-]*)?$/.test(line)) {
70
70
  return true;
71
71
  }
72
- if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)(?:\s+(?:(?:Daily\s+\$[\d.,]+\/\$[\d.,]+|\$[\d.,]+\/\$[\d.,]+)(?:\s+(?:Rst|Exp\+?)\s+[-:\d]+)?|(?:\d+[hdw]|Weekly|Monthly)\s+\d{1,3}%(?:\s+Rst\s+[-:\d]+)?|(?:error|unsupported|unavailable)))$/.test(line)) {
72
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|RC(?:-[^\s]+)?)(?:\s+(?:(?:Daily\s+\$[\d.,]+\/\$[\d.,]+|\$[\d.,]+\/\$[\d.,]+)(?:\s+(?:Rst|Exp\+?)\s+[-:\d]+)?|(?:\d+[hdw]|Weekly|Monthly)\s+\d{1,3}%(?:\s+Rst\s+[-:\d]+)?|(?:error|unsupported|unavailable)))$/.test(line)) {
73
73
  return true;
74
74
  }
75
- if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)(?:\s+(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|S7d\d{1,3})(?:\s+(?:R|E\+?)\d[\d:.-]*)?)$/.test(line)) {
75
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|RC(?:-[^\s]+)?)(?:\s+(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|(?:S7d|O7d|OA7d|Co7d)\d{1,3})(?:\s+(?:R|E\+?)\d[\d:.-]*)?)$/.test(line)) {
76
76
  return true;
77
77
  }
78
- if (/^(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|S7d\d{1,3}|(?:R|E\+?)\d[\d:.-]*))(?:\s+(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|S7d\d{1,3}|(?:R|E\+?)\d[\d:.-]*)))*$/.test(line)) {
78
+ if (/^(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|(?:S7d|O7d|OA7d|Co7d)\d{1,3}|(?:R|E\+?)\d[\d:.-]*))(?:\s+(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|(?:S7d|O7d|OA7d|Co7d)\d{1,3}|(?:R|E\+?)\d[\d:.-]*)))*$/.test(line)) {
79
79
  return true;
80
80
  }
81
- if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)(?:\s+(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|S7d\d{1,3}|(?:R|E\+?)\d[\d:.-]*)))*$/.test(line)) {
81
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|RC(?:-[^\s]+)?)(?:\s+(?:(?:D\$?[\d.,]+\/\$?[\d.,]+|B(?:[¥$-])?[\d.,]+|(?:\d+[hdw]|[DWM])\d{1,3}|(?:S7d|O7d|OA7d|Co7d)\d{1,3}|(?:R|E\+?)\d[\d:.-]*)))*$/.test(line)) {
82
82
  return true;
83
83
  }
84
84
  return false;
@@ -15,6 +15,16 @@ export function createTitleApplicator(deps) {
15
15
  balance: quota.balance ? { ...quota.balance } : undefined,
16
16
  windows: quota.windows?.map((win) => ({ ...win })),
17
17
  }));
18
+ const sameProviderIDs = (left, right) => {
19
+ if (left.length !== right.length)
20
+ return false;
21
+ const rightSet = new Set(right);
22
+ for (const value of left) {
23
+ if (!rightSet.has(value))
24
+ return false;
25
+ }
26
+ return true;
27
+ };
18
28
  const applyTitle = async (sessionID) => {
19
29
  if (!deps.config.sidebar.enabled)
20
30
  return false;
@@ -82,16 +92,23 @@ export function createTitleApplicator(deps) {
82
92
  const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
83
93
  const view = deps.getTitleView?.(sessionID) ??
84
94
  resolveTitleView({ config: deps.config });
95
+ const panelQuotaProviders = Array.from(new Set(Object.keys(usage.providers)));
85
96
  const quotaProviders = Array.from(new Set(view === 'compact'
86
97
  ? selectDesktopCompactProviderIDs(usage, deps.config)
87
- : Object.keys(usage.providers)));
98
+ : panelQuotaProviders));
88
99
  const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
89
100
  ? await deps.getQuotaSnapshots(quotaProviders)
90
101
  : [];
102
+ const panelQuotas = deps.config.sidebar.showQuota && panelQuotaProviders.length > 0
103
+ ? sameProviderIDs(quotaProviders, panelQuotaProviders)
104
+ ? quotas
105
+ : await deps.getQuotaSnapshots(panelQuotaProviders)
106
+ : [];
91
107
  sessionState.sidebarPanel = {
92
108
  version: 1,
93
109
  updatedAt: Date.now(),
94
110
  usage: toCachedSessionUsage(usage),
111
+ panelQuotas: cloneQuotas(collapseQuotaSnapshots(panelQuotas)),
95
112
  quotas: cloneQuotas(collapseQuotaSnapshots(quotas)),
96
113
  };
97
114
  stateMutated = true;
package/dist/tui.tsx CHANGED
@@ -6,11 +6,16 @@ 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
+ sidebarPanelQuotaSnapshots,
17
+ type SidebarQuotaGroup,
18
+ } from './tui_helpers.js'
14
19
  import {
15
20
  loadConfig,
16
21
  loadState,
@@ -31,7 +36,7 @@ type SidebarPanelData = {
31
36
  enabled: boolean
32
37
  width: number
33
38
  usageLines: string[]
34
- quotaLines: string[]
39
+ quotaGroups: SidebarQuotaGroup[]
35
40
  compactTitle?: string
36
41
  }
37
42
 
@@ -96,7 +101,7 @@ async function loadSidebarPanel(
96
101
  enabled,
97
102
  width,
98
103
  usageLines: [],
99
- quotaLines: [],
104
+ quotaGroups: [],
100
105
  compactTitle: session?.lastAppliedTitle,
101
106
  }
102
107
  }
@@ -104,8 +109,8 @@ async function loadSidebarPanel(
104
109
  const usageLines = usage
105
110
  ? renderSidebarUsageLines(usage, panelConfig(config))
106
111
  : []
107
- const quotaLines = renderSidebarQuotaLines(
108
- session?.sidebarPanel?.quotas || [],
112
+ const quotaGroups = renderSidebarQuotaGroups(
113
+ sidebarPanelQuotaSnapshots(session?.sidebarPanel),
109
114
  panelConfig(config),
110
115
  )
111
116
 
@@ -113,7 +118,7 @@ async function loadSidebarPanel(
113
118
  enabled,
114
119
  width,
115
120
  usageLines,
116
- quotaLines,
121
+ quotaGroups,
117
122
  compactTitle,
118
123
  }
119
124
  }
@@ -193,20 +198,83 @@ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
193
198
  return panel
194
199
  }
195
200
 
196
- function sectionHeading(api: TuiPluginApi, value: string) {
197
- return <text fg={api.theme.current.textMuted}>{value}</text>
201
+ function SectionHeading(props: {
202
+ api: TuiPluginApi
203
+ value: string
204
+ collapsible?: boolean
205
+ open?: boolean
206
+ summary?: string
207
+ onToggle?: () => void
208
+ }) {
209
+ const clickable = () => props.collapsible === true && props.onToggle
210
+
211
+ return (
212
+ <box
213
+ flexDirection="row"
214
+ gap={1}
215
+ onMouseDown={() => {
216
+ if (!clickable()) return
217
+ props.onToggle?.()
218
+ }}
219
+ >
220
+ <Show when={props.collapsible}>
221
+ <text fg={props.api.theme.current.text}>{props.open ? '▼' : '▶'}</text>
222
+ </Show>
223
+ <text fg={props.api.theme.current.text}>
224
+ <b>{props.value}</b>
225
+ <Show when={props.summary}>
226
+ <span style={{ fg: props.api.theme.current.textMuted }}>
227
+ {' '}
228
+ {props.summary}
229
+ </span>
230
+ </Show>
231
+ </text>
232
+ </box>
233
+ )
198
234
  }
199
235
 
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))
236
+ function quotaToneColor(api: TuiPluginApi, tone: SidebarQuotaGroup['tone']) {
237
+ const theme = api.theme.current
238
+ if (tone === 'success') return theme.success
239
+ if (tone === 'warning') return theme.warning
240
+ if (tone === 'error') return theme.error
241
+ return theme.textMuted
242
+ }
243
+
244
+ function QuotaGroupBlock(props: {
245
+ api: TuiPluginApi
246
+ group: SidebarQuotaGroup
247
+ bullet: boolean
248
+ }) {
249
+ const content = (
250
+ <box gap={0}>
251
+ <text>
252
+ <span style={{ fg: props.api.theme.current.text }}>
253
+ {props.group.shortLabel}
254
+ </span>
255
+ <Show when={props.group.detail}>
256
+ <span style={{ fg: props.api.theme.current.textMuted }}>
257
+ {' '}
258
+ {props.group.detail}
259
+ </span>
260
+ </Show>
261
+ </text>
262
+ <For each={props.group.continuationLines}>
263
+ {(line) => <text fg={props.api.theme.current.textMuted}>{line}</text>}
264
+ </For>
265
+ </box>
266
+ )
267
+
268
+ return (
269
+ <Show when={props.bullet} fallback={content}>
270
+ <box flexDirection="row" gap={1}>
271
+ <text flexShrink={0} fg={quotaToneColor(props.api, props.group.tone)}>
272
+
273
+ </text>
274
+ {content}
275
+ </box>
276
+ </Show>
277
+ )
210
278
  }
211
279
 
212
280
  function fallbackUsageCostLineFromTitle(title: string, width: number) {
@@ -220,6 +288,7 @@ function fallbackUsageCostLineFromTitle(title: string, width: number) {
220
288
 
221
289
  function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
222
290
  const panel = useSidebarPanelData(props.api, () => props.sessionID)
291
+ const [quotaOpen, setQuotaOpen] = createSignal(true)
223
292
  const width = createMemo(
224
293
  () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
225
294
  )
@@ -234,22 +303,32 @@ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
234
303
  const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
235
304
  return costLine ? [...liveLines, costLine] : liveLines
236
305
  })
237
- const quotaLines = createMemo(() => {
238
- const liveLines = panel()?.quotaLines || []
239
- if (liveLines.length > 0) return liveLines
240
- return fallbackQuotaLinesFromTitle(compactTitle(), width())
306
+ const quotaGroups = createMemo(() => {
307
+ const liveGroups = panel()?.quotaGroups || []
308
+ if (liveGroups.length > 0) return liveGroups
309
+ return fallbackQuotaGroupsFromTitle(compactTitle(), width())
241
310
  })
242
311
  const hasUsage = createMemo(() => usageLines().length > 0)
243
- const hasQuota = createMemo(() => quotaLines().length > 0)
312
+ const hasQuota = createMemo(() => quotaGroups().length > 0)
313
+ const quotaBullets = createMemo(() => quotaGroupsUseBullets(quotaGroups()))
314
+ const quotaCollapsible = createMemo(() =>
315
+ quotaGroupsAreCollapsible(quotaGroups()),
316
+ )
317
+ const quotaSummary = createMemo(() => {
318
+ if (!quotaCollapsible() || quotaOpen()) return undefined
319
+ return quotaGroupsSummary(quotaGroups())
320
+ })
244
321
 
245
322
  return (
246
323
  <box gap={0}>
247
324
  <Show when={hasUsage()}>
248
325
  <box gap={0}>
249
- {sectionHeading(props.api, 'USAGE')}
326
+ <SectionHeading api={props.api} value="Usage" />
250
327
  <box gap={0}>
251
328
  <For each={usageLines()}>
252
- {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
329
+ {(line) => (
330
+ <text fg={props.api.theme.current.textMuted}>{line}</text>
331
+ )}
253
332
  </For>
254
333
  </box>
255
334
  </box>
@@ -257,12 +336,27 @@ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
257
336
 
258
337
  <Show when={hasQuota()}>
259
338
  <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>
339
+ <SectionHeading
340
+ api={props.api}
341
+ value="Quota"
342
+ collapsible={quotaCollapsible()}
343
+ open={quotaOpen()}
344
+ summary={quotaSummary()}
345
+ onToggle={() => setQuotaOpen((value) => !value)}
346
+ />
347
+ <Show when={!quotaCollapsible() || quotaOpen()}>
348
+ <box gap={0}>
349
+ <For each={quotaGroups()}>
350
+ {(group) => (
351
+ <QuotaGroupBlock
352
+ api={props.api}
353
+ group={group}
354
+ bullet={quotaBullets()}
355
+ />
356
+ )}
357
+ </For>
358
+ </box>
359
+ </Show>
266
360
  </box>
267
361
  </Show>
268
362
  </box>
@@ -292,10 +386,13 @@ function SidebarTitleView(props: {
292
386
 
293
387
  return (
294
388
  <box gap={0} paddingRight={1}>
295
- {sectionHeading(props.api, 'TITLE')}
296
389
  <box gap={0}>
297
390
  <For each={titleLines()}>
298
- {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
391
+ {(line) => (
392
+ <text fg={props.api.theme.current.text}>
393
+ <b>{line}</b>
394
+ </text>
395
+ )}
299
396
  </For>
300
397
  <Show when={shareLine()}>
301
398
  <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
@@ -0,0 +1,16 @@
1
+ import type { QuotaSidebarConfig, QuotaSnapshot, SidebarPanelState } 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 sidebarPanelQuotaSnapshots(panel?: SidebarPanelState): QuotaSnapshot[];
13
+ export declare function fallbackQuotaGroupsFromTitle(title: string, width: number): SidebarQuotaGroup[];
14
+ export declare function quotaGroupsUseBullets(groups: SidebarQuotaGroup[]): boolean;
15
+ export declare function quotaGroupsAreCollapsible(groups: SidebarQuotaGroup[]): boolean;
16
+ export declare function quotaGroupsSummary(groups: SidebarQuotaGroup[]): string | undefined;
@@ -0,0 +1,146 @@
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 = [
73
+ ...safe.matchAll(/\b(?:\d+[hdw]|[DWM]|S7d|O7d|OA7d|Co7d)(\d{1,3})\b/gi),
74
+ ]
75
+ .map((match) => Number(match[1]))
76
+ .filter((value) => Number.isFinite(value));
77
+ if (percents.length === 0)
78
+ return 'muted';
79
+ const remaining = Math.min(...percents);
80
+ if (remaining <= 5)
81
+ return 'error';
82
+ if (remaining <= 20)
83
+ return 'warning';
84
+ return 'success';
85
+ }
86
+ export function renderSidebarQuotaGroups(quotas, config) {
87
+ const visibleQuotaCount = collapseQuotaSnapshots(quotas).filter((quota) => VISIBLE_QUOTA_STATUSES.has(quota.status)).length;
88
+ const renderConfig = visibleQuotaCount > 1
89
+ ? {
90
+ ...config,
91
+ sidebar: {
92
+ ...config.sidebar,
93
+ width: Math.max(8, config.sidebar.width - 2),
94
+ },
95
+ }
96
+ : config;
97
+ return renderSidebarQuotaLineGroups(quotas, renderConfig).map((group) => {
98
+ const parsed = parseQuotaLineParts(group.lines);
99
+ return {
100
+ providerID: group.quota.providerID,
101
+ status: group.quota.status,
102
+ tone: quotaTone(group.quota),
103
+ shortLabel: parsed.shortLabel,
104
+ detail: parsed.detail,
105
+ continuationLines: parsed.continuationLines,
106
+ };
107
+ });
108
+ }
109
+ export function sidebarPanelQuotaSnapshots(panel) {
110
+ return panel?.panelQuotas || panel?.quotas || [];
111
+ }
112
+ export function fallbackQuotaGroupsFromTitle(title, width) {
113
+ const parts = (title || '')
114
+ .split(' | ')
115
+ .map((part) => part.trim())
116
+ .filter(Boolean);
117
+ const quotaParts = parts
118
+ .slice(1)
119
+ .filter((part) => !/^Cd\d/.test(part) && !/^Est\b/.test(part));
120
+ if (quotaParts.length === 0)
121
+ return [];
122
+ const contentWidth = quotaParts.length > 1 ? Math.max(1, width - 2) : width;
123
+ return quotaParts.map((part, index) => {
124
+ const line = fitLine(part, contentWidth);
125
+ const parsed = parseQuotaLineParts([line]);
126
+ return {
127
+ providerID: `fallback:${index}`,
128
+ status: 'ok',
129
+ tone: fallbackQuotaTone(parsed.detail),
130
+ shortLabel: parsed.shortLabel,
131
+ detail: parsed.detail,
132
+ continuationLines: parsed.continuationLines,
133
+ };
134
+ });
135
+ }
136
+ export function quotaGroupsUseBullets(groups) {
137
+ return groups.length > 1;
138
+ }
139
+ export function quotaGroupsAreCollapsible(groups) {
140
+ return groups.length > 2;
141
+ }
142
+ export function quotaGroupsSummary(groups) {
143
+ if (groups.length === 0)
144
+ return undefined;
145
+ return `(${groups.length})`;
146
+ }