@leo000001/opencode-quota-sidebar 2.0.26 → 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/README.md CHANGED
@@ -3,21 +3,21 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@leo000001/opencode-quota-sidebar.svg)](https://www.npmjs.com/package/@leo000001/opencode-quota-sidebar)
4
4
  [![license](https://img.shields.io/npm/l/@leo000001/opencode-quota-sidebar.svg)](https://github.com/xihuai18/opencode-quota-sidebar/blob/main/LICENSE)
5
5
 
6
- OpenCode plugin: show token usage and subscription quota in the session sidebar title.
6
+ OpenCode plugin: show token usage and subscription quota in TUI sidebar panels and compact shared session titles.
7
7
 
8
8
  ![Example sidebar title with usage and quota](./assets/OpenCode-Quota-Sidebar.png)
9
9
 
10
10
  ## Install
11
11
 
12
- If you use the OpenCode installer flow, the package manifest now advertises both `server` and `tui` targets.
12
+ The package manifest advertises both `server` and `tui` targets, but OpenCode loads those targets from different config files at runtime.
13
13
 
14
- If you configure files manually, add the server entry to `opencode.json` and the TUI entry to `tui.json`:
14
+ If you configure the plugin manually, you must add the server entry to `opencode.json` and the TUI entry to `tui.json`:
15
15
 
16
16
  `opencode.json`
17
17
 
18
18
  ```json
19
19
  {
20
- "plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
20
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.26"]
21
21
  }
22
22
  ```
23
23
 
@@ -25,11 +25,13 @@ If you configure files manually, add the server entry to `opencode.json` and the
25
25
 
26
26
  ```json
27
27
  {
28
- "plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
28
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.26"]
29
29
  }
30
30
  ```
31
31
 
32
32
  Note for OpenCode `>=1.2.15`: TUI settings and TUI plugins live in `tui.json`, while server plugins stay in `opencode.json`.
33
+
34
+ If you use an installer flow that reads `oc-plugin` targets and patches config for you, it can populate both files automatically. Simply having the package installed in `node_modules` or listed only in `opencode.json` is not enough for the TUI runtime to load `./tui`.
33
35
  This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
34
36
 
35
37
  If you prefer automatic upgrades, you can still use `@latest`, but pinning an exact version makes behavior easier to reproduce when debugging.
@@ -55,14 +57,14 @@ Add the built server file to your `opencode.json` and the TUI file to your `tui.
55
57
 
56
58
  ```json
57
59
  {
58
- "plugin": ["file:///ABSOLUTE/PATH/opencode-quota-sidebar/dist/tui.js"]
60
+ "plugin": ["file:///ABSOLUTE/PATH/opencode-quota-sidebar/dist/tui.tsx"]
59
61
  }
60
62
  ```
61
63
 
62
64
  On Windows, use forward slashes, for example:
63
65
 
64
66
  - `file:///D:/Lab/opencode-quota-sidebar/dist/index.js`
65
- - `file:///D:/Lab/opencode-quota-sidebar/dist/tui.js`
67
+ - `file:///D:/Lab/opencode-quota-sidebar/dist/tui.tsx`
66
68
 
67
69
  ## Supported quota providers
68
70
 
@@ -82,11 +84,11 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
82
84
 
83
85
  - TUI sidebar can render a dedicated block layout instead of stuffing telemetry into the shared title:
84
86
  - `TITLE`: clean base session title
85
- - `CONTEXT`: one compact line such as `242k tok 24% ctx`
86
87
  - `USAGE`: compact request/input/output/cache lines such as `R184 I189k O53.2k`, `CR31.4k CW3.2k Cd66%`, `Est $12.8`
87
88
  - `QUOTA`: one compact provider group per provider, with indented continuation lines for multi-window quotas or balances
88
- - while active, the TUI plugin temporarily deactivates the built-in `internal:sidebar-context` block so the custom one-line context row does not duplicate it
89
+ - while active, the TUI plugin temporarily deactivates the built-in `internal:sidebar-context` block so the custom panel does not duplicate it
89
90
  - Shared `session.title` now stays compact in `auto` mode. Desktop, Web UI / `serve`, and TUI all keep the shared title on a compact single line such as `<base> | OAI 5h80 R16:20 W70 R04-03 | RC D88.9/60 B260 | Cd66% | Est$0.12`.
91
+ - TUI panel data is read from persisted day-chunk session state (`usage` + `sidebarPanel`) so entering or resuming a session can render from persistence first; compact-title parsing remains only a defensive fallback.
90
92
  - `sidebar.titleMode=multiline` is still available as a legacy fallback when you explicitly want the old multiline title decoration path.
91
93
  - `sidebar.titleMode` can force `auto`, `multiline`, or `compact` if the heuristic does not match your workflow.
92
94
  - Multi-client caveat: the shared title is still one `session.title` for every client. The new TUI sidebar blocks avoid polluting that shared title, but Desktop/Web still see the compact shared title rather than a sidebar panel.
@@ -97,7 +99,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
97
99
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
98
100
  - Custom tools:
99
101
  - `quota_summary` — generate usage report for session/day/week/month (full markdown report + toast). The markdown report and toast keep the full human-readable wording; they do not switch to compact sidebar tokens.
100
- - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
102
+ - `quota_show` — toggle shared title decoration on/off (state persists across sessions)
101
103
  - 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
102
104
  - Quota connectors:
103
105
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
@@ -142,6 +144,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
142
144
  - `parentID` (when the session is a subagent child session)
143
145
  - `expiryToastShown` (session-level dedupe for automatic expiry reminders)
144
146
  - cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cached-ratio reporting and legacy cache classification
147
+ - `sidebarPanel` cache used by the TUI sidebar plugin (`version`, cached usage, compact quota snapshots`) so `TITLE / USAGE / QUOTA` can render from persisted structure on session open/resume
145
148
  - incremental aggregation cursor
146
149
 
147
150
  Notes on cache bucket persistence:
@@ -343,13 +346,47 @@ Other defaults:
343
346
  }
344
347
  ```
345
348
 
349
+ ### Common minimal configs
350
+
351
+ Keep the shared title compact and let TUI render the structured panel:
352
+
353
+ ```json
354
+ {
355
+ "sidebar": {
356
+ "enabled": true,
357
+ "titleMode": "auto",
358
+ "showCost": true,
359
+ "showQuota": true,
360
+ "includeChildren": true
361
+ }
362
+ }
363
+ ```
364
+
365
+ Enable XYAI Vibe quota with account login:
366
+
367
+ ```json
368
+ {
369
+ "quota": {
370
+ "providers": {
371
+ "xyai-vibe": {
372
+ "enabled": true,
373
+ "login": {
374
+ "username": "your-account",
375
+ "password": "your-password"
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ ```
382
+
346
383
  ### Notes
347
384
 
348
385
  - `sidebar.showCost` controls API-cost visibility in the TUI `USAGE` block, the compact shared title, `quota_summary` markdown report, and toast message.
349
386
  - `quota_summary` follows the same reset compaction rules for short windows in its subscription section (`5h` / `1d` / `Daily` show time, long windows show date, RightCode `Exp` stays date-only).
350
387
  - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
351
388
  - `sidebar.titleMode` defaults to `auto`: the shared `session.title` stays compact for Desktop, Web UI / `serve`, and TUI alike. The rich TUI layout comes from the dedicated TUI plugin slots instead of a multiline shared title. Use `multiline` only if you explicitly want the legacy decorated-title path, or `compact` to force compact titles everywhere.
352
- - The TUI plugin renders `TITLE`, `CONTEXT`, `USAGE`, and `QUOTA` blocks in the sidebar and temporarily disables the built-in `internal:sidebar-context` block while it is active.
389
+ - The TUI plugin renders `TITLE`, `USAGE`, and `QUOTA` blocks in the sidebar and temporarily disables the built-in `internal:sidebar-context` block while it is active.
353
390
  - The shared `session.title` is still one string per session for all clients. TUI sidebar blocks avoid polluting that title, but Desktop/Web still see the compact shared title rather than a TUI panel.
354
391
  - `sidebar.multilineTitle` is kept for backward compatibility, but `sidebar.titleMode` now controls the active policy.
355
392
  - `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
@@ -404,8 +441,6 @@ Typical layout:
404
441
  ```text
405
442
  TITLE
406
443
  Fix quota adapter matching
407
- CONTEXT
408
- 242k tok 24% ctx
409
444
  USAGE
410
445
  R184 I189k O53.2k
411
446
  CR31.4k CW3.2k Cd66%
@@ -534,7 +569,7 @@ Shorthand rules:
534
569
  - `B260` / `B¥10.2` = balance
535
570
  - `Cd66%` = cached ratio (`cache.read / (input + cache.read)`)
536
571
  - `Est$0.12` = equivalent API cost estimate
537
- - Compact shared titles omit `R/I/O/CR/CW`; the dedicated TUI sidebar keeps the richer `CONTEXT / USAGE / QUOTA` breakdown.
572
+ - Compact shared titles omit `R/I/O/CR/CW`; the dedicated TUI sidebar keeps the richer `TITLE / USAGE / QUOTA` breakdown.
538
573
  - Order is `base | quota... | usage-summary` for compact shared titles.
539
574
 
540
575
  `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/format.d.ts CHANGED
@@ -1,19 +1,13 @@
1
1
  import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
2
2
  import { type UsageSummary } from './usage.js';
3
3
  export type TitleView = 'multiline' | 'compact';
4
- export declare const TUI_ACTIVE_MS: number;
5
4
  /**
6
5
  * Truncate `value` to at most `width` terminal cells.
7
6
  * Keep plain text only (no ANSI) to avoid renderer corruption.
8
7
  */
9
8
  export declare function fitLine(value: string, width: number): string;
10
- export declare function isDesktopClient(): boolean;
11
9
  export declare function resolveTitleView(opts: {
12
10
  config: QuotaSidebarConfig;
13
- sessionID?: string;
14
- tuiSessionID?: string;
15
- tuiActiveAt?: number;
16
- now?: number;
17
11
  }): TitleView;
18
12
  export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, config: QuotaSidebarConfig, now?: number): string[];
19
13
  /**
@@ -29,8 +23,14 @@ export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, con
29
23
  */
30
24
  export declare function renderSidebarTitle(baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig, view?: TitleView): string;
31
25
  export declare function renderSidebarContextLine(tokens: number, percent: number | undefined, width: number): string;
32
- export declare function renderSidebarUsageLines(usage: UsageSummary, config: QuotaSidebarConfig): string[];
26
+ export declare function renderSidebarUsageLines(usage: UsageSummary, config: QuotaSidebarConfig, options?: {
27
+ showCost?: boolean;
28
+ }): string[];
33
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
+ }[];
34
34
  export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
35
35
  showCost?: boolean;
36
36
  }): string;
package/dist/format.js CHANGED
@@ -1,7 +1,6 @@
1
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
- export const TUI_ACTIVE_MS = 15 * 60_000;
5
4
  /** M6 fix: handle negative, NaN, Infinity gracefully. */
6
5
  function shortNumber(value, decimals = 1) {
7
6
  if (!Number.isFinite(value) || value < 0)
@@ -169,16 +168,12 @@ function formatRequestsLabel(value, short = false) {
169
168
  const count = shortNumber(value, 1);
170
169
  return short ? `Req ${count}` : `Requests ${count}`;
171
170
  }
172
- export function isDesktopClient() {
173
- return process.env.OPENCODE_CLIENT === 'desktop';
174
- }
175
171
  export function resolveTitleView(opts) {
172
+ void opts;
176
173
  if (opts.config.sidebar.titleMode === 'compact')
177
174
  return 'compact';
178
175
  if (opts.config.sidebar.titleMode === 'multiline')
179
176
  return 'multiline';
180
- if (isDesktopClient())
181
- return 'compact';
182
177
  return 'compact';
183
178
  }
184
179
  function desktopCompactSettings(config) {
@@ -592,18 +587,21 @@ export function renderSidebarContextLine(tokens, percent, width) {
592
587
  }
593
588
  return fitLine(parts.join(' '), width);
594
589
  }
595
- export function renderSidebarUsageLines(usage, config) {
590
+ export function renderSidebarUsageLines(usage, config, options) {
596
591
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
597
592
  const cacheMetrics = getCacheCoverageMetrics(usage);
598
593
  return usageDetailLines(usage, cacheMetrics, {
599
594
  width,
600
- showCost: config.sidebar.showCost,
595
+ showCost: options?.showCost ?? config.sidebar.showCost,
601
596
  numberToken: panelNumber,
602
597
  costToken: (value) => `Est ${formatApiCostValue(value)}`,
603
598
  cacheReadFirst: true,
604
599
  }).map((line) => fitLine(line, width));
605
600
  }
606
601
  export function renderSidebarQuotaLines(quotas, config) {
602
+ return renderSidebarQuotaLineGroups(quotas, config).flatMap((group) => group.lines);
603
+ }
604
+ export function renderSidebarQuotaLineGroups(quotas, config) {
607
605
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
608
606
  const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
609
607
  const labelWidth = visibleQuotas.reduce((max, item) => {
@@ -611,14 +609,18 @@ export function renderSidebarQuotaLines(quotas, config) {
611
609
  return Math.max(max, stringCellWidth(label));
612
610
  }, 0);
613
611
  return visibleQuotas
614
- .flatMap((item) => compactQuotaWide(item, labelWidth, {
615
- width,
616
- wrapLines: config.sidebar.wrapQuotaLines,
617
- forceWrapped: false,
618
- 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)),
619
622
  }))
620
- .filter((line) => Boolean(line))
621
- .map((line) => fitLine(line, width));
623
+ .filter((group) => group.lines.length > 0);
622
624
  }
623
625
  /**
624
626
  * Multi-window quota format for sidebar.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, TUI_ACTIVE_MS, } from './format.js';
1
+ import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
2
2
  import { createQuotaRuntime } from './quota.js';
3
3
  import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
4
4
  import { debug, swallow } from './helpers.js';
@@ -116,23 +116,6 @@ export async function QuotaSidebarPlugin(input) {
116
116
  });
117
117
  const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
118
118
  const summarizeForTool = usageService.summarizeForTool;
119
- let tuiSessionID;
120
- let tuiActiveAt = 0;
121
- let tuiTimer;
122
- const armTuiTimer = () => {
123
- if (tuiTimer)
124
- clearTimeout(tuiTimer);
125
- if (!tuiSessionID)
126
- return;
127
- tuiTimer = setTimeout(() => {
128
- const id = tuiSessionID;
129
- tuiActiveAt = 0;
130
- tuiTimer = undefined;
131
- if (id)
132
- scheduleTitleRefresh(id, 0);
133
- }, TUI_ACTIVE_MS);
134
- tuiTimer.unref?.();
135
- };
136
119
  // title apply / refresh lifecycle
137
120
  let scheduleTitleRefresh = (sessionID, delay = 250) => {
138
121
  void sessionID;
@@ -168,7 +151,7 @@ export async function QuotaSidebarPlugin(input) {
168
151
  markDirty,
169
152
  scheduleSave,
170
153
  renderSidebarTitle,
171
- getTitleView: (sessionID) => resolveTitleView({ config, sessionID, tuiSessionID, tuiActiveAt }),
154
+ getTitleView: () => resolveTitleView({ config }),
172
155
  getQuotaSnapshots,
173
156
  summarizeSessionUsageForDisplay,
174
157
  scheduleParentRefreshIfSafe,
@@ -208,8 +191,6 @@ export async function QuotaSidebarPlugin(input) {
208
191
  .catch(swallow('startup:refreshAllTouchedTitles'));
209
192
  }
210
193
  const shutdown = async () => {
211
- if (tuiTimer)
212
- clearTimeout(tuiTimer);
213
194
  await Promise.race([
214
195
  startupTitleWork,
215
196
  new Promise((resolve) => setTimeout(resolve, 5_000)),
@@ -341,14 +322,6 @@ export async function QuotaSidebarPlugin(input) {
341
322
  },
342
323
  onSessionDeleted: async (session) => {
343
324
  await flushSave().catch(swallow('onSessionDeleted:flushSave'));
344
- if (tuiSessionID === session.id) {
345
- tuiSessionID = undefined;
346
- tuiActiveAt = 0;
347
- if (tuiTimer) {
348
- clearTimeout(tuiTimer);
349
- tuiTimer = undefined;
350
- }
351
- }
352
325
  descendantsResolver.invalidateForAncestors(session.parentID);
353
326
  descendantsResolver.invalidateForAncestors(session.id);
354
327
  usageService.forgetSession(session.id);
@@ -371,20 +344,9 @@ export async function QuotaSidebarPlugin(input) {
371
344
  }
372
345
  },
373
346
  onTuiActivity: async () => {
374
- const stale = Boolean(tuiSessionID) && Date.now() - tuiActiveAt > TUI_ACTIVE_MS;
375
- tuiActiveAt = Date.now();
376
- armTuiTimer();
377
- if (stale && tuiSessionID) {
378
- titleRefresh.schedule(tuiSessionID, 0);
379
- }
347
+ return;
380
348
  },
381
349
  onTuiSessionSelect: async (sessionID) => {
382
- const prev = tuiSessionID;
383
- tuiSessionID = sessionID;
384
- armTuiTimer();
385
- if (prev && prev !== sessionID) {
386
- titleRefresh.schedule(prev, 0);
387
- }
388
350
  titleRefresh.schedule(sessionID, 0);
389
351
  },
390
352
  onMessageRemoved: async (info) => {
@@ -48,14 +48,21 @@ class ChunkCache {
48
48
  key(rootPath, dateKey) {
49
49
  return `${path.resolve(rootPath)}::${dateKey}`;
50
50
  }
51
- get(rootPath, dateKey) {
52
- const entry = this.cache.get(this.key(rootPath, dateKey));
51
+ get(rootPath, dateKey, stamp) {
52
+ const key = this.key(rootPath, dateKey);
53
+ const entry = this.cache.get(key);
53
54
  if (!entry)
54
55
  return undefined;
56
+ if (!stamp ||
57
+ entry.stamp.mtimeMs !== stamp.mtimeMs ||
58
+ entry.stamp.size !== stamp.size) {
59
+ this.cache.delete(key);
60
+ return undefined;
61
+ }
55
62
  entry.accessedAt = Date.now();
56
63
  return entry.sessions;
57
64
  }
58
- set(rootPath, dateKey, sessions) {
65
+ set(rootPath, dateKey, sessions, stamp) {
59
66
  if (this.cache.size >= this.maxSize) {
60
67
  // Evict least recently accessed
61
68
  let oldestKey;
@@ -72,6 +79,7 @@ class ChunkCache {
72
79
  this.cache.set(this.key(rootPath, dateKey), {
73
80
  sessions,
74
81
  accessedAt: Date.now(),
82
+ stamp,
75
83
  });
76
84
  }
77
85
  invalidate(rootPath, dateKey) {
@@ -82,15 +90,21 @@ const chunkCache = new ChunkCache();
82
90
  export async function readDayChunk(rootPath, dateKey) {
83
91
  if (!isDateKey(dateKey))
84
92
  return {};
85
- const cached = chunkCache.get(rootPath, dateKey);
86
- if (cached)
87
- return cached;
88
93
  const filePath = chunkFilePath(rootPath, dateKey);
89
94
  const stat = await fs.lstat(filePath).catch(() => undefined);
90
95
  if (stat?.isSymbolicLink()) {
96
+ chunkCache.invalidate(rootPath, dateKey);
91
97
  debug(`refusing to read symlink chunk: ${filePath}`);
92
98
  return {};
93
99
  }
100
+ if (!stat?.isFile()) {
101
+ chunkCache.invalidate(rootPath, dateKey);
102
+ return {};
103
+ }
104
+ const stamp = { mtimeMs: stat.mtimeMs, size: stat.size };
105
+ const cached = chunkCache.get(rootPath, dateKey, stamp);
106
+ if (cached)
107
+ return cached;
94
108
  const parsed = await fs
95
109
  .readFile(filePath, 'utf8')
96
110
  .then((value) => JSON.parse(value))
@@ -107,7 +121,7 @@ export async function readDayChunk(rootPath, dateKey) {
107
121
  acc[sessionID] = parsedSession;
108
122
  return acc;
109
123
  }, {});
110
- chunkCache.set(rootPath, dateKey, sessions);
124
+ chunkCache.set(rootPath, dateKey, sessions, stamp);
111
125
  return sessions;
112
126
  }
113
127
  /**
@@ -182,10 +182,14 @@ function parseQuotaSnapshots(value) {
182
182
  function parseSidebarPanel(value) {
183
183
  if (!isRecord(value))
184
184
  return undefined;
185
+ const version = asNumber(value.version, 1);
186
+ if (version !== 1)
187
+ return undefined;
185
188
  const updatedAt = asNumber(value.updatedAt, 0);
186
189
  if (!updatedAt)
187
190
  return undefined;
188
191
  return {
192
+ version: 1,
189
193
  updatedAt,
190
194
  usage: parseCachedUsage(value.usage),
191
195
  quotas: parseQuotaSnapshots(value.quotas),
@@ -2,6 +2,7 @@ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normali
2
2
  import { toCachedSessionUsage } from './usage.js';
3
3
  import { swallow, debug, mapConcurrent } from './helpers.js';
4
4
  import { resolveTitleView, selectDesktopCompactProviderIDs, } from './format.js';
5
+ import { collapseQuotaSnapshots } from './quota_render.js';
5
6
  export function createTitleApplicator(deps) {
6
7
  const pendingAppliedTitle = new Map();
7
8
  const recentRestore = new Map();
@@ -15,7 +16,7 @@ export function createTitleApplicator(deps) {
15
16
  windows: quota.windows?.map((win) => ({ ...win })),
16
17
  }));
17
18
  const applyTitle = async (sessionID) => {
18
- if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
19
+ if (!deps.config.sidebar.enabled)
19
20
  return false;
20
21
  let stateMutated = false;
21
22
  const session = await deps.client.session
@@ -80,7 +81,7 @@ export function createTitleApplicator(deps) {
80
81
  }
81
82
  const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
82
83
  const view = deps.getTitleView?.(sessionID) ??
83
- resolveTitleView({ config: deps.config, sessionID });
84
+ resolveTitleView({ config: deps.config });
84
85
  const quotaProviders = Array.from(new Set(view === 'compact'
85
86
  ? selectDesktopCompactProviderIDs(usage, deps.config)
86
87
  : Object.keys(usage.providers)));
@@ -88,14 +89,21 @@ export function createTitleApplicator(deps) {
88
89
  ? await deps.getQuotaSnapshots(quotaProviders)
89
90
  : [];
90
91
  sessionState.sidebarPanel = {
92
+ version: 1,
91
93
  updatedAt: Date.now(),
92
94
  usage: toCachedSessionUsage(usage),
93
- quotas: cloneQuotas(quotas),
95
+ quotas: cloneQuotas(collapseQuotaSnapshots(quotas)),
94
96
  };
95
97
  stateMutated = true;
96
- const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
97
- if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
98
+ if (!deps.state.titleEnabled) {
99
+ if (stateMutated) {
100
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
101
+ }
102
+ deps.scheduleSave();
103
+ deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
98
104
  return false;
105
+ }
106
+ const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
99
107
  if (canonicalizeTitleForCompare(nextTitle) ===
100
108
  canonicalizeTitleForCompare(session.data.title)) {
101
109
  if (looksDecorated(session.data.title)) {
package/dist/tui.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource @opentui/solid */
1
2
  import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
2
3
  declare const plugin: TuiPluginModule & {
3
4
  id: string;
package/dist/tui.tsx ADDED
@@ -0,0 +1,459 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type {
3
+ TuiPlugin,
4
+ TuiPluginApi,
5
+ TuiPluginModule,
6
+ } from '@opencode-ai/plugin/tui'
7
+ import { createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
8
+
9
+ import { fitLine, renderSidebarUsageLines } from './format.js'
10
+ import {
11
+ fallbackQuotaGroupsFromTitle,
12
+ quotaGroupsAreCollapsible,
13
+ quotaGroupsSummary,
14
+ quotaGroupsUseBullets,
15
+ renderSidebarQuotaGroups,
16
+ type SidebarQuotaGroup,
17
+ } from './tui_helpers.js'
18
+ import {
19
+ loadConfig,
20
+ loadState,
21
+ quotaConfigPaths,
22
+ resolveOpencodeDataDir,
23
+ stateFilePath,
24
+ } from './storage.js'
25
+ import { looksDecorated, normalizeBaseTitle } from './title.js'
26
+ import type { QuotaSidebarConfig } from './types.js'
27
+ import { fromCachedSessionUsage, summarizeMessages } from './usage.js'
28
+
29
+ const id = 'leo.quota-sidebar'
30
+ const INTERNAL_CONTEXT_PLUGIN_ID = 'internal:sidebar-context'
31
+ const SECTION_INDENT = 2
32
+ const DEFAULT_WIDTH = 36
33
+
34
+ type SidebarPanelData = {
35
+ enabled: boolean
36
+ width: number
37
+ usageLines: string[]
38
+ quotaGroups: SidebarQuotaGroup[]
39
+ compactTitle?: string
40
+ }
41
+
42
+ const latestCompactTitles = new Map<string, string>()
43
+ const [compactTitleVersion, setCompactTitleVersion] = createSignal(0)
44
+
45
+ function directoryPath(api: TuiPluginApi) {
46
+ return api.state.path.directory || process.cwd()
47
+ }
48
+
49
+ function worktreePath(api: TuiPluginApi) {
50
+ return api.state.path.worktree || directoryPath(api)
51
+ }
52
+
53
+ function panelConfig(config: QuotaSidebarConfig): QuotaSidebarConfig {
54
+ return {
55
+ ...config,
56
+ sidebar: {
57
+ ...config.sidebar,
58
+ width: Math.max(8, config.sidebar.width - SECTION_INDENT),
59
+ },
60
+ }
61
+ }
62
+
63
+ function resolveCompactTitle(sessionID: string, persistedTitle?: string) {
64
+ const liveTitle = latestCompactTitles.get(sessionID)
65
+ if (liveTitle && looksDecorated(liveTitle)) return liveTitle
66
+ if (persistedTitle && looksDecorated(persistedTitle)) return persistedTitle
67
+ return liveTitle || persistedTitle
68
+ }
69
+
70
+ async function loadSidebarPanel(
71
+ api: TuiPluginApi,
72
+ sessionID: string,
73
+ ): Promise<SidebarPanelData> {
74
+ const statePath = stateFilePath(resolveOpencodeDataDir())
75
+ const config = await loadConfig(
76
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
77
+ )
78
+ // Session payload lives in day chunks that the server updates from a
79
+ // separate process, so TUI should re-read persisted state instead of keeping
80
+ // an extra full-state cache here.
81
+ const state = await loadState(statePath)
82
+ const session = state.sessions[sessionID]
83
+ const enabled = config.sidebar.enabled
84
+ const width = Math.max(8, config.sidebar.width - SECTION_INDENT)
85
+ const liveEntries = api.state.session.messages(sessionID).map((info) => ({
86
+ info,
87
+ })) as Parameters<typeof summarizeMessages>[0]
88
+
89
+ const liveUsage = summarizeMessages(liveEntries, 0, 1)
90
+ const cachedUsage = session?.sidebarPanel?.usage || session?.usage
91
+ const usage = cachedUsage
92
+ ? fromCachedSessionUsage(cachedUsage)
93
+ : liveUsage.assistantMessages > 0
94
+ ? liveUsage
95
+ : undefined
96
+ const compactTitle = resolveCompactTitle(sessionID, session?.lastAppliedTitle)
97
+
98
+ if (!enabled) {
99
+ return {
100
+ enabled,
101
+ width,
102
+ usageLines: [],
103
+ quotaGroups: [],
104
+ compactTitle: session?.lastAppliedTitle,
105
+ }
106
+ }
107
+
108
+ const usageLines = usage
109
+ ? renderSidebarUsageLines(usage, panelConfig(config))
110
+ : []
111
+ const quotaGroups = renderSidebarQuotaGroups(
112
+ session?.sidebarPanel?.quotas || [],
113
+ panelConfig(config),
114
+ )
115
+
116
+ return {
117
+ enabled,
118
+ width,
119
+ usageLines,
120
+ quotaGroups,
121
+ compactTitle,
122
+ }
123
+ }
124
+
125
+ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
126
+ const [panel, setPanel] = createSignal<SidebarPanelData | undefined>()
127
+ let disposed = false
128
+ let loadVersion = 0
129
+
130
+ const reload = () => {
131
+ const currentVersion = ++loadVersion
132
+ const currentSessionID = sessionID()
133
+ void loadSidebarPanel(api, currentSessionID)
134
+ .then((next) => {
135
+ if (disposed || currentVersion !== loadVersion) return
136
+ setPanel(next)
137
+ })
138
+ .catch((error) => {
139
+ if (disposed || currentVersion !== loadVersion) return
140
+ void error
141
+ })
142
+ }
143
+
144
+ reload()
145
+
146
+ const timers = new Set<ReturnType<typeof setTimeout>>()
147
+ const queueRefresh = (delay = 250) => {
148
+ const timer = setTimeout(() => {
149
+ timers.delete(timer)
150
+ reload()
151
+ }, delay)
152
+ timers.add(timer)
153
+ }
154
+
155
+ const scheduleRefresh = () => {
156
+ queueRefresh(300)
157
+ queueRefresh(1_000)
158
+ }
159
+
160
+ // Bulk session sync populates messages asynchronously without emitting the
161
+ // real-time message.updated events we listen to below. Retry a few times on
162
+ // mount so historical sessions can render usage once the sync finishes.
163
+ queueRefresh(500)
164
+ queueRefresh(1_500)
165
+ queueRefresh(4_000)
166
+
167
+ const unsubscribers = [
168
+ api.event.on('session.updated', (event) => {
169
+ if (event.properties.info.id === sessionID()) {
170
+ scheduleRefresh()
171
+ }
172
+ }),
173
+ api.event.on('message.updated', (event) => {
174
+ if (event.properties.info.sessionID === sessionID()) {
175
+ scheduleRefresh()
176
+ }
177
+ }),
178
+ api.event.on('message.removed', (event) => {
179
+ if (event.properties.sessionID === sessionID()) {
180
+ scheduleRefresh()
181
+ }
182
+ }),
183
+ api.event.on('tui.session.select', (event) => {
184
+ if (event.properties.sessionID === sessionID()) {
185
+ scheduleRefresh()
186
+ }
187
+ }),
188
+ ]
189
+
190
+ onCleanup(() => {
191
+ disposed = true
192
+ for (const timer of timers) clearTimeout(timer)
193
+ timers.clear()
194
+ for (const unsubscribe of unsubscribers) unsubscribe()
195
+ })
196
+
197
+ return panel
198
+ }
199
+
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
+ )
233
+ }
234
+
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
+ )
277
+ }
278
+
279
+ function fallbackUsageCostLineFromTitle(title: string, width: number) {
280
+ const est = (title || '')
281
+ .split(' | ')
282
+ .map((part) => part.trim())
283
+ .find((part) => /^Est\$/.test(part) || /^Est\s+\$/.test(part))
284
+ if (!est) return undefined
285
+ return fitLine(est.replace(/^Est\$/, 'Est $'), width)
286
+ }
287
+
288
+ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
289
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
290
+ const [quotaOpen, setQuotaOpen] = createSignal(true)
291
+ const width = createMemo(
292
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
293
+ )
294
+ const compactTitle = createMemo(() => {
295
+ compactTitleVersion()
296
+ return resolveCompactTitle(props.sessionID, panel()?.compactTitle) || ''
297
+ })
298
+ const usageLines = createMemo(() => {
299
+ const liveLines = panel()?.usageLines || []
300
+ const hasCostLine = liveLines.some((line) => /^Est\b/.test(line))
301
+ if (hasCostLine) return liveLines
302
+ const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
303
+ return costLine ? [...liveLines, costLine] : liveLines
304
+ })
305
+ const quotaGroups = createMemo(() => {
306
+ const liveGroups = panel()?.quotaGroups || []
307
+ if (liveGroups.length > 0) return liveGroups
308
+ return fallbackQuotaGroupsFromTitle(compactTitle(), width())
309
+ })
310
+ const hasUsage = createMemo(() => usageLines().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
+ })
320
+
321
+ return (
322
+ <box gap={0}>
323
+ <Show when={hasUsage()}>
324
+ <box gap={0}>
325
+ <SectionHeading api={props.api} value="Usage" />
326
+ <box gap={0}>
327
+ <For each={usageLines()}>
328
+ {(line) => (
329
+ <text fg={props.api.theme.current.textMuted}>{line}</text>
330
+ )}
331
+ </For>
332
+ </box>
333
+ </box>
334
+ </Show>
335
+
336
+ <Show when={hasQuota()}>
337
+ <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
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>
359
+ </box>
360
+ </Show>
361
+ </box>
362
+ )
363
+ }
364
+
365
+ function SidebarTitleView(props: {
366
+ api: TuiPluginApi
367
+ sessionID: string
368
+ title: string
369
+ shareURL?: string
370
+ }) {
371
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
372
+ const width = createMemo(
373
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
374
+ )
375
+ const titleLines = createMemo(() => {
376
+ const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session'
377
+ return baseTitle
378
+ .split(/\r?\n/)
379
+ .filter(Boolean)
380
+ .map((line) => fitLine(line, width()))
381
+ })
382
+ const shareLine = createMemo(() =>
383
+ props.shareURL ? fitLine(props.shareURL, width()) : undefined,
384
+ )
385
+
386
+ return (
387
+ <box gap={0} paddingRight={1}>
388
+ <box gap={0}>
389
+ <For each={titleLines()}>
390
+ {(line) => (
391
+ <text fg={props.api.theme.current.text}>
392
+ <b>{line}</b>
393
+ </text>
394
+ )}
395
+ </For>
396
+ <Show when={shareLine()}>
397
+ <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
398
+ </Show>
399
+ </box>
400
+ </box>
401
+ )
402
+ }
403
+
404
+ const tui: TuiPlugin = async (api) => {
405
+ const config = await loadConfig(
406
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
407
+ )
408
+ let didDeactivateContext = false
409
+ if (config.sidebar.enabled) {
410
+ const contextPlugin = api.plugins
411
+ .list()
412
+ .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID)
413
+ if (contextPlugin?.active) {
414
+ didDeactivateContext = await api.plugins
415
+ .deactivate(INTERNAL_CONTEXT_PLUGIN_ID)
416
+ .catch(() => false)
417
+ }
418
+ }
419
+ api.lifecycle.onDispose(() => {
420
+ if (!didDeactivateContext) return
421
+ return api.plugins
422
+ .activate(INTERNAL_CONTEXT_PLUGIN_ID)
423
+ .then(() => undefined)
424
+ .catch(() => undefined)
425
+ })
426
+
427
+ api.slots.register({
428
+ order: 100,
429
+ slots: {
430
+ sidebar_title(
431
+ _ctx: unknown,
432
+ props: { session_id: string; title: string; share_url?: string },
433
+ ) {
434
+ if (latestCompactTitles.get(props.session_id) !== props.title) {
435
+ latestCompactTitles.set(props.session_id, props.title)
436
+ setCompactTitleVersion((value) => value + 1)
437
+ }
438
+ return (
439
+ <SidebarTitleView
440
+ api={api}
441
+ sessionID={props.session_id}
442
+ title={props.title}
443
+ shareURL={props.share_url}
444
+ />
445
+ )
446
+ },
447
+ sidebar_content(_ctx: unknown, props: { session_id: string }) {
448
+ return <SidebarContentView api={api} sessionID={props.session_id} />
449
+ },
450
+ },
451
+ })
452
+ }
453
+
454
+ const plugin: TuiPluginModule & { id: string } = {
455
+ id,
456
+ tui,
457
+ }
458
+
459
+ export default plugin
@@ -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/dist/types.d.ts CHANGED
@@ -110,6 +110,7 @@ export type CachedSessionUsage = {
110
110
  providers: Record<string, CachedProviderUsage>;
111
111
  };
112
112
  export type SidebarPanelState = {
113
+ version: 1;
113
114
  updatedAt: number;
114
115
  usage?: CachedSessionUsage;
115
116
  quotas?: QuotaSnapshot[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.26",
4
- "description": "OpenCode plugin that shows quota and token usage in session titles",
3
+ "version": "3.0.1",
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",
7
7
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "./tui": {
14
14
  "types": "./dist/tui.d.ts",
15
- "default": "./dist/tui.js"
15
+ "default": "./dist/tui.tsx"
16
16
  }
17
17
  },
18
18
  "oc-plugin": [
@@ -21,6 +21,7 @@
21
21
  ],
22
22
  "files": [
23
23
  "dist/*.js",
24
+ "dist/tui.tsx",
24
25
  "dist/*.d.ts",
25
26
  "dist/providers/**/*.js",
26
27
  "dist/providers/**/*.d.ts",
@@ -33,7 +34,7 @@
33
34
  ],
34
35
  "scripts": {
35
36
  "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('tsconfig.tsbuildinfo',{force:true});\"",
36
- "build": "npm run clean && tsc -p tsconfig.json",
37
+ "build": "npm run clean && tsc -p tsconfig.json && node ./scripts/prepare-tui-dist.mjs",
37
38
  "prepare": "npm run build",
38
39
  "prepack": "npm run build",
39
40
  "prepublishOnly": "npm run typecheck && npm run build && npm test",
package/dist/tui.js DELETED
@@ -1,181 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
2
- /** @jsxImportSource @opentui/solid */
3
- import fs from 'node:fs/promises';
4
- import { createMemo, createResource, createSignal, For, onCleanup, Show, } from 'solid-js';
5
- import { fitLine, renderSidebarContextLine, renderSidebarQuotaLines, renderSidebarUsageLines, } from './format.js';
6
- import { loadConfig, loadState, quotaConfigPaths, resolveOpencodeDataDir, stateFilePath, } from './storage.js';
7
- import { normalizeBaseTitle } from './title.js';
8
- import { fromCachedSessionUsage } from './usage.js';
9
- const id = 'leo.quota-sidebar';
10
- const INTERNAL_CONTEXT_PLUGIN_ID = 'internal:sidebar-context';
11
- const SECTION_INDENT = 2;
12
- const DEFAULT_WIDTH = 36;
13
- function latestAssistantWithOutput(messages) {
14
- for (let index = messages.length - 1; index >= 0; index -= 1) {
15
- const message = messages[index];
16
- if (!message || message.role !== 'assistant')
17
- continue;
18
- if (message.tokens.output <= 0)
19
- continue;
20
- return message;
21
- }
22
- return undefined;
23
- }
24
- const stateCache = new Map();
25
- async function loadStateCached(filePath) {
26
- const stat = await fs.stat(filePath).catch(() => undefined);
27
- const mtimeMs = stat?.mtimeMs ?? -1;
28
- const cached = stateCache.get(filePath);
29
- if (cached && cached.mtimeMs === mtimeMs)
30
- return cached.state;
31
- const state = await loadState(filePath);
32
- stateCache.set(filePath, { mtimeMs, state });
33
- return state;
34
- }
35
- function directoryPath(api) {
36
- return api.state.path.directory || process.cwd();
37
- }
38
- function worktreePath(api) {
39
- return api.state.path.worktree || directoryPath(api);
40
- }
41
- function panelConfig(config) {
42
- return {
43
- ...config,
44
- sidebar: {
45
- ...config.sidebar,
46
- width: Math.max(8, config.sidebar.width - SECTION_INDENT),
47
- },
48
- };
49
- }
50
- async function loadSidebarPanel(api, sessionID) {
51
- const config = await loadConfig(quotaConfigPaths(worktreePath(api), directoryPath(api)));
52
- const state = await loadStateCached(stateFilePath(resolveOpencodeDataDir()));
53
- const session = state.sessions[sessionID];
54
- const enabled = config.sidebar.enabled && state.titleEnabled;
55
- const width = Math.max(8, config.sidebar.width - SECTION_INDENT);
56
- if (!enabled || !session?.sidebarPanel?.usage) {
57
- return {
58
- enabled,
59
- width,
60
- usageLines: [],
61
- quotaLines: [],
62
- };
63
- }
64
- const usage = fromCachedSessionUsage(session.sidebarPanel.usage);
65
- const usageLines = renderSidebarUsageLines(usage, panelConfig(config));
66
- const quotaLines = renderSidebarQuotaLines(session.sidebarPanel.quotas || [], panelConfig(config));
67
- return {
68
- enabled,
69
- width,
70
- usageLines,
71
- quotaLines,
72
- };
73
- }
74
- function useSidebarPanelData(api, sessionID) {
75
- const [refresh, setRefresh] = createSignal(0);
76
- const [panel] = createResource(() => `${sessionID()}:${refresh()}`, async () => loadSidebarPanel(api, sessionID()));
77
- let timer;
78
- const scheduleRefresh = () => {
79
- if (timer)
80
- clearTimeout(timer);
81
- timer = setTimeout(() => setRefresh((value) => value + 1), 250);
82
- };
83
- const unsubscribers = [
84
- api.event.on('session.updated', (event) => {
85
- if (event.properties.info.id === sessionID())
86
- scheduleRefresh();
87
- }),
88
- api.event.on('message.updated', (event) => {
89
- if (event.properties.info.sessionID === sessionID())
90
- scheduleRefresh();
91
- }),
92
- api.event.on('message.removed', (event) => {
93
- if (event.properties.sessionID === sessionID())
94
- scheduleRefresh();
95
- }),
96
- api.event.on('tui.session.select', (event) => {
97
- if (event.properties.sessionID === sessionID())
98
- scheduleRefresh();
99
- }),
100
- ];
101
- onCleanup(() => {
102
- if (timer)
103
- clearTimeout(timer);
104
- for (const unsubscribe of unsubscribers)
105
- unsubscribe();
106
- });
107
- return panel;
108
- }
109
- function sectionHeading(api, value) {
110
- return _jsx("text", { fg: api.theme.current.textMuted, children: value });
111
- }
112
- function ContextSection(props) {
113
- const messages = createMemo(() => props.api.state.session.messages(props.sessionID));
114
- const contextLine = createMemo(() => {
115
- const last = latestAssistantWithOutput(messages());
116
- if (!last)
117
- return undefined;
118
- const tokens = last.tokens.input +
119
- last.tokens.output +
120
- last.tokens.reasoning +
121
- last.tokens.cache.read +
122
- last.tokens.cache.write;
123
- const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID];
124
- const percent = model?.limit.context && model.limit.context > 0
125
- ? (tokens / model.limit.context) * 100
126
- : undefined;
127
- return renderSidebarContextLine(tokens, percent, props.width());
128
- });
129
- return (_jsx(Show, { when: contextLine(), children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'CONTEXT'), _jsx("box", { paddingLeft: SECTION_INDENT, children: _jsx("text", { fg: props.api.theme.current.text, children: contextLine() }) })] }) }));
130
- }
131
- function SidebarContentView(props) {
132
- const panel = useSidebarPanelData(props.api, () => props.sessionID);
133
- const width = createMemo(() => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT);
134
- return (_jsx(Show, { when: panel()?.enabled, children: _jsxs("box", { gap: 0, children: [_jsx(ContextSection, { api: props.api, sessionID: props.sessionID, width: width }), _jsx(Show, { when: (panel()?.usageLines.length || 0) > 0, children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'USAGE'), _jsx("box", { paddingLeft: SECTION_INDENT, gap: 0, children: _jsx(For, { each: panel()?.usageLines || [], children: (line) => (_jsx("text", { fg: props.api.theme.current.text, children: line })) }) })] }) }), _jsx(Show, { when: (panel()?.quotaLines.length || 0) > 0, children: _jsxs("box", { paddingTop: 1, gap: 0, children: [sectionHeading(props.api, 'QUOTA'), _jsx("box", { paddingLeft: SECTION_INDENT, gap: 0, children: _jsx(For, { each: panel()?.quotaLines || [], children: (line) => (_jsx("text", { fg: props.api.theme.current.text, children: line })) }) })] }) })] }) }));
135
- }
136
- function SidebarTitleView(props) {
137
- const panel = useSidebarPanelData(props.api, () => props.sessionID);
138
- const width = createMemo(() => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT);
139
- const titleLines = createMemo(() => {
140
- const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session';
141
- return baseTitle
142
- .split(/\r?\n/)
143
- .filter(Boolean)
144
- .map((line) => fitLine(line, width()));
145
- });
146
- const shareLine = createMemo(() => props.shareURL ? fitLine(props.shareURL, width()) : undefined);
147
- return (_jsx(Show, { when: panel()?.enabled, fallback: _jsxs("box", { gap: 0, paddingRight: 1, children: [_jsx(For, { each: titleLines(), children: (line) => _jsx("text", { fg: props.api.theme.current.text, children: line }) }), _jsx(Show, { when: shareLine(), children: _jsx("text", { fg: props.api.theme.current.textMuted, children: shareLine() }) })] }), children: _jsxs("box", { gap: 0, paddingRight: 1, children: [sectionHeading(props.api, 'TITLE'), _jsxs("box", { paddingLeft: SECTION_INDENT, gap: 0, children: [_jsx(For, { each: titleLines(), children: (line) => _jsx("text", { fg: props.api.theme.current.text, children: line }) }), _jsx(Show, { when: shareLine(), children: _jsx("text", { fg: props.api.theme.current.textMuted, children: shareLine() }) })] })] }) }));
148
- }
149
- const tui = async (api) => {
150
- const contextPlugin = api.plugins
151
- .list()
152
- .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID);
153
- const shouldRestoreContext = Boolean(contextPlugin?.enabled && contextPlugin?.active);
154
- if (contextPlugin?.active) {
155
- void api.plugins.deactivate(INTERNAL_CONTEXT_PLUGIN_ID).catch(() => false);
156
- }
157
- api.lifecycle.onDispose(() => {
158
- if (!shouldRestoreContext)
159
- return;
160
- return api.plugins
161
- .activate(INTERNAL_CONTEXT_PLUGIN_ID)
162
- .then(() => undefined)
163
- .catch(() => undefined);
164
- });
165
- api.slots.register({
166
- order: 100,
167
- slots: {
168
- sidebar_title(_ctx, props) {
169
- return (_jsx(SidebarTitleView, { api: api, sessionID: props.session_id, title: props.title, shareURL: props.share_url }));
170
- },
171
- sidebar_content(_ctx, props) {
172
- return _jsx(SidebarContentView, { api: api, sessionID: props.session_id });
173
- },
174
- },
175
- });
176
- };
177
- const plugin = {
178
- id,
179
- tui,
180
- };
181
- export default plugin;