@leo000001/opencode-quota-sidebar 1.0.2 → 1.2.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
@@ -17,6 +17,8 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
17
17
  }
18
18
  ```
19
19
 
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: []`).
21
+
20
22
  ## Development (build from source)
21
23
 
22
24
  ```bash
@@ -55,6 +57,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
57
  - line 5: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
56
58
  - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`, with multi-window continuation lines indented (e.g. ` Weekly 70% Rst 03-01`)
57
59
  - 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
60
+ - 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.
58
61
  - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
59
62
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
60
63
  - Custom tools:
@@ -81,6 +84,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
81
84
  - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
82
85
  - per-session title state (`baseTitle`, `lastAppliedTitle`)
83
86
  - `createdAt`
87
+ - `parentID` (when the session is a subagent child session)
84
88
  - cached usage summary used by `quota_summary`
85
89
  - incremental aggregation cursor
86
90
 
@@ -103,6 +107,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
103
107
 
104
108
  - Node.js: >= 18 (for `fetch` + `AbortController`)
105
109
  - OpenCode: plugin SDK `@opencode-ai/plugin` ^1.2.10
110
+ - OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
106
111
 
107
112
  ## Optional commands
108
113
 
@@ -141,7 +146,11 @@ Create `quota-sidebar.config.json` under your project root:
141
146
  "enabled": true,
142
147
  "width": 36,
143
148
  "showCost": true,
144
- "showQuota": true
149
+ "showQuota": true,
150
+ "includeChildren": true,
151
+ "childrenMaxDepth": 6,
152
+ "childrenMaxSessions": 128,
153
+ "childrenConcurrency": 5
145
154
  },
146
155
  "quota": {
147
156
  "refreshMs": 300000,
@@ -166,11 +175,18 @@ Create `quota-sidebar.config.json` under your project root:
166
175
  Notes:
167
176
 
168
177
  - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
178
+ - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
179
+ - `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
180
+ - `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
181
+ - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
182
+ - `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
169
183
  - `output` now includes reasoning tokens. Reasoning is no longer rendered as a separate line.
170
184
  - API cost excludes reasoning tokens from output billing (uses `tokens.output` only for output-price multiplication).
171
185
  - `quota.providers` is the extensible per-adapter switch map.
172
186
  - 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.
173
187
 
188
+ `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.
189
+
174
190
  ## Debug logging
175
191
 
176
192
  Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
@@ -196,9 +212,9 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
196
212
  - OpenAI OAuth token refresh is disabled by default; set
197
213
  `quota.refreshAccessToken=true` if you want the plugin to refresh access
198
214
  tokens when expired.
199
- - State file writes refuse to follow symlinks to prevent symlink attacks.
200
- - The `OPENCODE_QUOTA_DATA_HOME` env var can override the home directory for
201
- testing; do not set this in production.
215
+ - State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
216
+ - The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
217
+ path (for testing); do not set this in production.
202
218
 
203
219
  ## Contributing
204
220
 
package/dist/cost.js CHANGED
@@ -65,9 +65,12 @@ export function guessModelCostDivisor(rates) {
65
65
  : MODEL_COST_DIVISOR_PER_TOKEN;
66
66
  }
67
67
  export function calcEquivalentApiCostForMessage(message, rates) {
68
+ // For providers that expose reasoning tokens separately, they are still
69
+ // billed as output/completion tokens (same unit price). Our UI also merges
70
+ // reasoning into the single Output statistic, so API cost should match that.
71
+ const billedOutput = message.tokens.output + message.tokens.reasoning;
68
72
  const rawCost = message.tokens.input * rates.input +
69
- // API cost intentionally excludes reasoning tokens.
70
- message.tokens.output * rates.output +
73
+ billedOutput * rates.output +
71
74
  message.tokens.cache.read * rates.cacheRead +
72
75
  message.tokens.cache.write * rates.cacheWrite;
73
76
  const divisor = guessModelCostDivisor(rates);
@@ -0,0 +1,22 @@
1
+ import type { Session } from '@opencode-ai/sdk';
2
+ export type DescendantsOptions = {
3
+ maxDepth: number;
4
+ maxSessions: number;
5
+ concurrency: number;
6
+ };
7
+ export type DescendantsDeps = {
8
+ listChildren: (sessionID: string) => Promise<Session[]>;
9
+ getParentID: (sessionID: string) => string | undefined;
10
+ onDiscover: (session: {
11
+ id: string;
12
+ title: string;
13
+ createdAt: number;
14
+ parentID: string | undefined;
15
+ }) => void;
16
+ debug?: (message: string) => void;
17
+ now?: () => number;
18
+ };
19
+ export declare function createDescendantsResolver(deps: DescendantsDeps): {
20
+ invalidateForAncestors: (sessionID: string | undefined) => void;
21
+ listDescendantSessionIDs: (sessionID: string, opts: DescendantsOptions) => Promise<string[]>;
22
+ };
@@ -0,0 +1,78 @@
1
+ import { mapConcurrent } from './helpers.js';
2
+ export function createDescendantsResolver(deps) {
3
+ const cache = new Map();
4
+ const ttlMs = 5_000;
5
+ const now = deps.now || Date.now;
6
+ const debug = deps.debug || (() => { });
7
+ const invalidateForAncestors = (sessionID) => {
8
+ if (!sessionID)
9
+ return;
10
+ const visited = new Set();
11
+ let current = sessionID;
12
+ for (let i = 0; i < 512 && current; i++) {
13
+ if (visited.has(current))
14
+ return;
15
+ visited.add(current);
16
+ cache.delete(current);
17
+ current = deps.getParentID(current);
18
+ }
19
+ };
20
+ const listDescendantSessionIDs = async (sessionID, opts) => {
21
+ if (opts.maxSessions <= 0) {
22
+ cache.set(sessionID, { sessionIDs: [], expiresAt: now() + ttlMs });
23
+ return [];
24
+ }
25
+ const cached = cache.get(sessionID);
26
+ if (cached && cached.expiresAt > now()) {
27
+ return cached.sessionIDs;
28
+ }
29
+ const visited = new Set([sessionID]);
30
+ const descendants = [];
31
+ let frontier = [sessionID];
32
+ let depth = 0;
33
+ while (frontier.length > 0 &&
34
+ depth < opts.maxDepth &&
35
+ descendants.length < opts.maxSessions) {
36
+ const levels = await mapConcurrent(frontier, opts.concurrency, async (id) => {
37
+ const children = await deps.listChildren(id).catch((error) => {
38
+ debug(`listChildren failed for ${id}: ${String(error)}`);
39
+ return [];
40
+ });
41
+ return children;
42
+ });
43
+ const nextFrontier = [];
44
+ for (const children of levels) {
45
+ for (const child of children) {
46
+ if (visited.has(child.id))
47
+ continue;
48
+ visited.add(child.id);
49
+ descendants.push(child.id);
50
+ deps.onDiscover({
51
+ id: child.id,
52
+ title: child.title,
53
+ createdAt: child.time.created,
54
+ parentID: child.parentID,
55
+ });
56
+ nextFrontier.push(child.id);
57
+ if (descendants.length >= opts.maxSessions)
58
+ break;
59
+ }
60
+ if (descendants.length >= opts.maxSessions)
61
+ break;
62
+ }
63
+ frontier = nextFrontier;
64
+ depth += 1;
65
+ }
66
+ cache.set(sessionID, { sessionIDs: descendants, expiresAt: now() + ttlMs });
67
+ const truncatedByDepth = depth >= opts.maxDepth && frontier.length > 0;
68
+ const truncatedByCount = descendants.length >= opts.maxSessions && frontier.length > 0;
69
+ if (truncatedByDepth || truncatedByCount) {
70
+ debug(`descendants truncated for ${sessionID}: depth=${depth}/${opts.maxDepth}, sessions=${descendants.length}/${opts.maxSessions}`);
71
+ }
72
+ return descendants;
73
+ };
74
+ return {
75
+ invalidateForAncestors,
76
+ listDescendantSessionIDs,
77
+ };
78
+ }
@@ -0,0 +1,8 @@
1
+ import type { AssistantMessage, Event, Session } from '@opencode-ai/sdk';
2
+ export declare function createEventDispatcher(handlers: {
3
+ onSessionCreated: (session: Session) => Promise<void>;
4
+ onSessionUpdated: (session: Session) => Promise<void>;
5
+ onSessionDeleted: (session: Session) => Promise<void>;
6
+ onMessageRemoved: (sessionID: string) => Promise<void>;
7
+ onAssistantMessageCompleted: (message: AssistantMessage) => Promise<void>;
8
+ }): (event: Event) => Promise<void>;
package/dist/events.js ADDED
@@ -0,0 +1,31 @@
1
+ function isAssistantMessage(message) {
2
+ return message.role === 'assistant';
3
+ }
4
+ export function createEventDispatcher(handlers) {
5
+ return async (event) => {
6
+ if (event.type === 'session.created') {
7
+ await handlers.onSessionCreated(event.properties.info);
8
+ return;
9
+ }
10
+ if (event.type === 'session.updated') {
11
+ await handlers.onSessionUpdated(event.properties.info);
12
+ return;
13
+ }
14
+ if (event.type === 'session.deleted') {
15
+ await handlers.onSessionDeleted(event.properties.info);
16
+ return;
17
+ }
18
+ if (event.type === 'message.removed') {
19
+ await handlers.onMessageRemoved(event.properties.sessionID);
20
+ return;
21
+ }
22
+ if (event.type !== 'message.updated')
23
+ return;
24
+ if (!isAssistantMessage(event.properties.info))
25
+ return;
26
+ const completed = event.properties.info.time.completed;
27
+ if (typeof completed !== 'number' || !Number.isFinite(completed))
28
+ return;
29
+ await handlers.onAssistantMessageCompleted(event.properties.info);
30
+ };
31
+ }
package/dist/format.js CHANGED
@@ -19,17 +19,113 @@ function shortNumber(value, decimals = 1) {
19
19
  function sidebarNumber(value) {
20
20
  return shortNumber(value, 1);
21
21
  }
22
+ function sanitizeLine(value) {
23
+ // Sidebars/titles must be plain text: no ANSI and no embedded newlines.
24
+ return (stripAnsi(value)
25
+ .replace(/\r?\n/g, ' ')
26
+ // Remove control characters that can corrupt TUI rendering.
27
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, ' '));
28
+ }
29
+ function isCombiningCodePoint(codePoint) {
30
+ return ((codePoint >= 0x0300 && codePoint <= 0x036f) ||
31
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
32
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
33
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
34
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f));
35
+ }
36
+ function isVariationSelector(codePoint) {
37
+ return ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
38
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef));
39
+ }
40
+ function isWideCodePoint(codePoint) {
41
+ // Based on commonly used fullwidth ranges (similar to string-width).
42
+ // This intentionally errs toward width=2 to avoid sidebar overflow.
43
+ if (codePoint >= 0x1100) {
44
+ if (codePoint <= 0x115f ||
45
+ codePoint === 0x2329 ||
46
+ codePoint === 0x232a ||
47
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
48
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
49
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
50
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
51
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
52
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
53
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
54
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)) {
55
+ return true;
56
+ }
57
+ }
58
+ // Emoji/symbol ranges (best-effort).
59
+ if ((codePoint >= 0x1f300 && codePoint <= 0x1f5ff) ||
60
+ (codePoint >= 0x1f600 && codePoint <= 0x1f64f) ||
61
+ (codePoint >= 0x1f680 && codePoint <= 0x1f6ff) ||
62
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
63
+ (codePoint >= 0x1fa70 && codePoint <= 0x1faff) ||
64
+ (codePoint >= 0x2600 && codePoint <= 0x26ff) ||
65
+ (codePoint >= 0x2700 && codePoint <= 0x27bf)) {
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ function cellWidthOfCodePoint(codePoint) {
71
+ if (codePoint === 0)
72
+ return 0;
73
+ // ZWJ sequences should not add width (best-effort).
74
+ if (codePoint === 0x200d)
75
+ return 0;
76
+ if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0))
77
+ return 0;
78
+ if (isCombiningCodePoint(codePoint))
79
+ return 0;
80
+ if (isVariationSelector(codePoint))
81
+ return 0;
82
+ return isWideCodePoint(codePoint) ? 2 : 1;
83
+ }
84
+ function stringCellWidth(value) {
85
+ let width = 0;
86
+ for (const char of value) {
87
+ width += cellWidthOfCodePoint(char.codePointAt(0) || 0);
88
+ }
89
+ return width;
90
+ }
91
+ function padEndCells(value, targetWidth) {
92
+ const current = stringCellWidth(value);
93
+ if (current >= targetWidth)
94
+ return value;
95
+ return `${value}${' '.repeat(targetWidth - current)}`;
96
+ }
97
+ function truncateToCellWidth(value, width) {
98
+ if (width <= 0)
99
+ return '';
100
+ let used = 0;
101
+ let out = '';
102
+ for (const char of value) {
103
+ const w = cellWidthOfCodePoint(char.codePointAt(0) || 0);
104
+ if (used + w > width)
105
+ break;
106
+ used += w;
107
+ out += char;
108
+ }
109
+ return out;
110
+ }
22
111
  /**
23
- * Truncate `value` to at most `width` visible characters.
112
+ * Truncate `value` to at most `width` terminal cells.
24
113
  * Keep plain text only (no ANSI) to avoid renderer corruption.
25
114
  */
26
115
  function fitLine(value, width) {
27
116
  if (width <= 0)
28
117
  return '';
29
- if (value.length > width) {
30
- return width <= 1 ? value.slice(0, width) : `${value.slice(0, width - 1)}~`;
31
- }
32
- return value;
118
+ const safe = sanitizeLine(value);
119
+ if (stringCellWidth(safe) <= width)
120
+ return safe;
121
+ if (width <= 1)
122
+ return truncateToCellWidth(safe, width);
123
+ const head = truncateToCellWidth(safe, width - 1);
124
+ // If we couldn't fit any characters with a suffix reserved, fall back to a
125
+ // best-effort truncation without the suffix.
126
+ if (!head)
127
+ return truncateToCellWidth(safe, width);
128
+ return `${head}~`;
33
129
  }
34
130
  function formatApiCostValue(value) {
35
131
  const safe = Number.isFinite(value) && value > 0 ? value : 0;
@@ -41,12 +137,16 @@ function formatApiCostLine(value) {
41
137
  function alignPairs(pairs, indent = ' ') {
42
138
  if (pairs.length === 0)
43
139
  return [];
44
- const labelWidth = Math.max(...pairs.map((pair) => pair.label.length), 0);
45
- return pairs.map((pair) => {
140
+ const safePairs = pairs.map((pair) => ({
141
+ label: sanitizeLine(pair.label || ''),
142
+ value: sanitizeLine(pair.value || ''),
143
+ }));
144
+ const labelWidth = Math.max(...safePairs.map((pair) => stringCellWidth(pair.label)), 0);
145
+ return safePairs.map((pair) => {
46
146
  if (!pair.label) {
47
147
  return `${indent}${' '.repeat(labelWidth)} ${pair.value}`;
48
148
  }
49
- return `${indent}${pair.label.padEnd(labelWidth)} ${pair.value}`;
149
+ return `${indent}${padEndCells(pair.label, labelWidth)} ${pair.value}`;
50
150
  });
51
151
  }
52
152
  /**
@@ -83,8 +183,8 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
83
183
  if (config.sidebar.showQuota) {
84
184
  const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
85
185
  const labelWidth = visibleQuotas.reduce((max, item) => {
86
- const label = quotaDisplayLabel(item);
87
- return Math.max(max, label.length);
186
+ const label = sanitizeLine(quotaDisplayLabel(item));
187
+ return Math.max(max, stringCellWidth(label));
88
188
  }, 0);
89
189
  const quotaItems = visibleQuotas
90
190
  .flatMap((item) => compactQuotaWide(item, labelWidth))
@@ -105,9 +205,9 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
105
205
  * Copilot: "Copilot Monthly 70% Rst 03-01"
106
206
  */
107
207
  function compactQuotaWide(quota, labelWidth = 0) {
108
- const label = quotaDisplayLabel(quota);
109
- const labelPadding = ' '.repeat(Math.max(0, labelWidth - label.length));
110
- const withLabel = (content) => `${label}${labelPadding} ${content}`;
208
+ const label = sanitizeLine(quotaDisplayLabel(quota));
209
+ const labelPadded = padEndCells(label, labelWidth);
210
+ const withLabel = (content) => `${labelPadded} ${content}`;
111
211
  if (quota.status === 'error')
112
212
  return [withLabel('Remaining ?')];
113
213
  if (quota.status === 'unsupported')
@@ -126,12 +226,12 @@ function compactQuotaWide(quota, labelWidth = 0) {
126
226
  : `${Math.round(win.remainingPercent)}%`;
127
227
  const parts = win.label
128
228
  ? showPercent
129
- ? [win.label, pct]
130
- : [win.label]
229
+ ? [sanitizeLine(win.label), pct]
230
+ : [sanitizeLine(win.label)]
131
231
  : [pct];
132
232
  const reset = compactReset(win.resetAt);
133
233
  if (reset) {
134
- parts.push(`${win.resetLabel || 'Rst'} ${reset}`);
234
+ parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
135
235
  }
136
236
  return parts.join(' ');
137
237
  };
@@ -203,6 +303,7 @@ function periodLabel(period) {
203
303
  }
204
304
  export function renderMarkdownReport(period, usage, quotas, options) {
205
305
  const showCost = options?.showCost !== false;
306
+ const mdCell = (value) => sanitizeLine(value).replace(/\|/g, '\\|');
206
307
  const rightCodeSubscriptionProviderIDs = new Set(collapseQuotaSnapshots(quotas)
207
308
  .filter((quota) => quota.adapterID === 'rightcode')
208
309
  .filter((quota) => quota.status === 'ok')
@@ -252,34 +353,37 @@ export function renderMarkdownReport(period, usage, quotas, options) {
252
353
  };
253
354
  const providerRows = Object.values(usage.providers)
254
355
  .sort((a, b) => b.total - a.total)
255
- .map((provider) => showCost
256
- ? `| ${provider.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)} |`
257
- : `| ${provider.providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`);
356
+ .map((provider) => {
357
+ const providerID = mdCell(provider.providerID);
358
+ return showCost
359
+ ? `| ${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)} |`
360
+ : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
361
+ });
258
362
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
259
363
  // Multi-window detail
260
364
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
261
365
  return quota.windows.map((win) => {
262
366
  if (win.showPercent === false) {
263
367
  const winLabel = win.label ? ` (${win.label})` : '';
264
- return `- ${quota.label}${winLabel}: ${quota.status} | reset ${dateLine(win.resetAt)}`;
368
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${dateLine(win.resetAt)}`);
265
369
  }
266
370
  const remaining = win.remainingPercent === undefined
267
371
  ? '-'
268
372
  : `${win.remainingPercent.toFixed(1)}%`;
269
373
  const winLabel = win.label ? ` (${win.label})` : '';
270
- return `- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${dateLine(win.resetAt)}`;
374
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${dateLine(win.resetAt)}`);
271
375
  });
272
376
  }
273
377
  if (quota.status === 'ok' && quota.balance) {
274
378
  return [
275
- `- ${quota.label}: ${quota.status} | balance ${quota.balance.currency}${quota.balance.amount.toFixed(2)}`,
379
+ mdCell(`- ${quota.label}: ${quota.status} | balance ${quota.balance.currency}${quota.balance.amount.toFixed(2)}`),
276
380
  ];
277
381
  }
278
382
  const remaining = quota.remainingPercent === undefined
279
383
  ? '-'
280
384
  : `${quota.remainingPercent.toFixed(1)}%`;
281
385
  return [
282
- `- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${dateLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`,
386
+ mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${dateLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
283
387
  ];
284
388
  });
285
389
  return [