@leo000001/opencode-quota-sidebar 2.0.23 → 2.0.26

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
@@ -9,15 +9,27 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
9
9
 
10
10
  ## Install
11
11
 
12
- Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to install it automatically on startup:
12
+ If you use the OpenCode installer flow, the package manifest now advertises both `server` and `tui` targets.
13
+
14
+ If you configure files manually, add the server entry to `opencode.json` and the TUI entry to `tui.json`:
15
+
16
+ `opencode.json`
17
+
18
+ ```json
19
+ {
20
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
21
+ }
22
+ ```
23
+
24
+ `tui.json`
13
25
 
14
26
  ```json
15
27
  {
16
- "plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
28
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.23"]
17
29
  }
18
30
  ```
19
31
 
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: []`).
32
+ Note for OpenCode `>=1.2.15`: TUI settings and TUI plugins live in `tui.json`, while server plugins stay in `opencode.json`.
21
33
  This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
22
34
 
23
35
  If you prefer automatic upgrades, you can still use `@latest`, but pinning an exact version makes behavior easier to reproduce when debugging.
@@ -29,7 +41,9 @@ npm install
29
41
  npm run build
30
42
  ```
31
43
 
32
- Add the built file to your `opencode.json`:
44
+ Add the built server file to your `opencode.json` and the TUI file to your `tui.json`:
45
+
46
+ `opencode.json`
33
47
 
34
48
  ```json
35
49
  {
@@ -37,7 +51,18 @@ Add the built file to your `opencode.json`:
37
51
  }
38
52
  ```
39
53
 
40
- On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/index.js"`
54
+ `tui.json`
55
+
56
+ ```json
57
+ {
58
+ "plugin": ["file:///ABSOLUTE/PATH/opencode-quota-sidebar/dist/tui.js"]
59
+ }
60
+ ```
61
+
62
+ On Windows, use forward slashes, for example:
63
+
64
+ - `file:///D:/Lab/opencode-quota-sidebar/dist/index.js`
65
+ - `file:///D:/Lab/opencode-quota-sidebar/dist/tui.js`
41
66
 
42
67
  ## Supported quota providers
43
68
 
@@ -55,21 +80,16 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
80
 
56
81
  ## Features
57
82
 
58
- - TUI session title uses a compact multiline sidebar layout:
59
- - line 1: original session title
60
- - line 2: blank separator
61
- - line 3: compact usage tokens such as `R3 I16.3k O916`
62
- - line 4+: compact cache tokens such as `CW300 CR31.4k Cd66%`
63
- - optional cost line: `Est$0.12`
64
- - quota lines also use compact tokens, for example `XYAI D$31.3/$90 R22:39` or `OAI 5h80 R22:18 W70 R04-03`
65
- - short windows (`5h`, `1d`, `Daily`) still show same-day resets as `HH:MM` and cross-day resets as `MM-DD HH:MM`; longer windows continue to show `MM-DD`
66
- - long quota content wraps across extra compact lines instead of dropping fields from the sidebar, and continuation lines align to the quota content column
67
- - Desktop automatically switches to a compact monitoring-style single-line title. It keeps recently used providers from the last `50` requests or last `60` minutes, expands all windows/balance for those selected providers in short form such as `OAI 5h80 R16:20 W70 R04-03` or `RC D88.9/60 B260`, and keeps only summary usage signals such as `Cd66%` and `Est$0.12`
68
- - Auto mode now prefers compact single-line titles everywhere except the actively selected TUI session. That means Desktop stays compact, Web UI / `serve` clients also use compact single-line titles, and the current TUI session keeps the compact multiline layout.
83
+ - TUI sidebar can render a dedicated block layout instead of stuffing telemetry into the shared title:
84
+ - `TITLE`: clean base session title
85
+ - `CONTEXT`: one compact line such as `242k tok 24% ctx`
86
+ - `USAGE`: compact request/input/output/cache lines such as `R184 I189k O53.2k`, `CR31.4k CW3.2k Cd66%`, `Est $12.8`
87
+ - `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
+ - 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`.
90
+ - `sidebar.titleMode=multiline` is still available as a legacy fallback when you explicitly want the old multiline title decoration path.
69
91
  - `sidebar.titleMode` can force `auto`, `multiline`, or `compact` if the heuristic does not match your workflow.
70
- - Auto-mode signals are intentionally minimal: Desktop is detected only from `OPENCODE_CLIENT=desktop`; TUI ownership comes from `tui.session.select`; `tui.command.execute` and `tui.prompt.append` only keep that last selected TUI session fresh.
71
- - Auto-mode freshness lasts `15` minutes. If no new TUI activity arrives in that window, the tracked TUI session is refreshed back to compact; the next TUI activity re-enables multiline for the last selected TUI session.
72
- - Multi-client caveat: the plugin tracks one global TUI-selected session and writes one shared `session.title`. In mixed TUI/Web or multi-TUI setups, the latest TUI selection wins for that session title. Use `sidebar.titleMode="compact"` or `"multiline"` if you need a stable forced policy.
92
+ - 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.
73
93
  - 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.
74
94
  - Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cached ratios are available), and `Quota`
75
95
  - Expiry reminders are shown in a separate `Expiry Soon` toast section only for providers with real subscription expiry timestamps, and each session shows that auto-reminder at most once
@@ -147,7 +167,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
147
167
  ## Compatibility
148
168
 
149
169
  - Node.js: >= 18 (for `fetch` + `AbortController`)
150
- - OpenCode: plugin SDK `@opencode-ai/plugin` ^1.2.10
170
+ - OpenCode: plugin SDK `@opencode-ai/plugin` ^1.3.5
151
171
  - OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
152
172
 
153
173
  ## Force refresh after npm update
@@ -156,7 +176,7 @@ If `npm view @leo000001/opencode-quota-sidebar version` shows a newer version bu
156
176
 
157
177
  Recommended recovery steps:
158
178
 
159
- 1. Pin the target plugin version in `opencode.json`.
179
+ 1. Pin the target plugin version in every config file that loads it (`opencode.json` and, if used manually, `tui.json`).
160
180
  2. Fully exit OpenCode.
161
181
  3. Delete any cached installed copies of the plugin.
162
182
  4. Start OpenCode again so it reinstalls the package.
@@ -325,13 +345,12 @@ Other defaults:
325
345
 
326
346
  ### Notes
327
347
 
328
- - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
348
+ - `sidebar.showCost` controls API-cost visibility in the TUI `USAGE` block, the compact shared title, `quota_summary` markdown report, and toast message.
329
349
  - `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).
330
350
  - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
331
- - `sidebar.titleMode` defaults to `auto`: Desktop is compact, the actively selected TUI session stays multiline, and everything else (including Web UI / `serve` clients) uses the compact single-line layout. Use `multiline` or `compact` to force one style.
332
- - `auto` relies on positive TUI signals: `tui.session.select` chooses the tracked TUI session, and recent `tui.command.execute` / `tui.prompt.append` activity only refreshes that tracked session. If no `tui.session.select` has been seen, command/prompt activity alone will not promote a session to multiline.
333
- - The TUI freshness window is `15` minutes. After that timeout, the tracked TUI session is refreshed back to compact; the next TUI activity re-promotes only the last selected TUI session.
334
- - The plugin tracks one global `tuiSessionID` and one shared session title per plugin process, not one title per window/tab/client. In multi-client or shared-server setups, different TUI/Web viewers can influence each other. If you need predictable rendering, force `sidebar.titleMode`.
351
+ - `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.
353
+ - 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.
335
354
  - `sidebar.multilineTitle` is kept for backward compatibility, but `sidebar.titleMode` now controls the active policy.
336
355
  - `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
337
356
  - `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
@@ -339,7 +358,7 @@ Other defaults:
339
358
  - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
340
359
  - `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
341
360
  - `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` control which recently used providers remain visible in compact single-line titles.
342
- - `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` only control provider filtering inside compact titles; they do not change TUI detection or the 15-minute freshness timeout.
361
+ - `sidebar.desktopCompact.recentRequests` and `sidebar.desktopCompact.recentMinutes` only control provider filtering inside compact titles; they do not affect the dedicated TUI sidebar panel.
343
362
  - `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
344
363
  - API cost bills reasoning tokens at the output rate (same as completion tokens).
345
364
  - API cost is computed from OpenCode model pricing metadata, not from `message.cost`. This keeps subscription-backed providers such as OpenAI OAuth usable for API-equivalent cost estimation even when OpenCode's measured cost is `0`.
@@ -378,7 +397,26 @@ These examples show the quota block portion of the sidebar title.
378
397
 
379
398
  ### TUI layout
380
399
 
381
- This section describes the multiline TUI layout. Desktop and Web UI / `serve` clients use the compact single-line format unless you force `sidebar.titleMode = "multiline"`.
400
+ The default TUI layout is a real sidebar panel, not a multiline shared title. Desktop and Web UI / `serve` clients keep the compact shared title unless you force `sidebar.titleMode = "multiline"`.
401
+
402
+ Typical layout:
403
+
404
+ ```text
405
+ TITLE
406
+ Fix quota adapter matching
407
+ CONTEXT
408
+ 242k tok 24% ctx
409
+ USAGE
410
+ R184 I189k O53.2k
411
+ CR31.4k CW3.2k Cd66%
412
+ Est $12.8
413
+ QUOTA
414
+ OAI 5h80 R16:20
415
+ W70 R04-03
416
+ Cop M78 R04-01
417
+ RC D$88.9/$60 E02-27
418
+ B260
419
+ ```
382
420
 
383
421
  ### Force modes
384
422
 
@@ -479,9 +517,9 @@ Cop unavailable
479
517
  OAI ?
480
518
  ```
481
519
 
482
- ### Desktop compact mode
520
+ ### Compact shared title mode
483
521
 
484
- Desktop always uses a compact monitoring-style single-line title. Recently used providers are selected from the last `50` assistant requests or last `60` minutes, and each selected provider expands all of its windows and balances in shorthand. To survive upstream Desktop truncation better, quota segments are emitted before usage summary signals:
522
+ Desktop and Web UI / `serve` use a compact monitoring-style single-line shared title in `auto` mode. Recently used providers are selected from the last `50` assistant requests or last `60` minutes, and each selected provider expands all of its windows and balances in shorthand. To survive upstream truncation better, quota segments are emitted before usage summary signals:
485
523
 
486
524
  ```text
487
525
  <base> | OAI 5h80 R16:20 W70 R04-03 | Cop M78 R04-01 | RC D88.9/60 B260 | Buzz B¥10.2 | Cd66% | Est$0.12
@@ -496,8 +534,8 @@ Shorthand rules:
496
534
  - `B260` / `B¥10.2` = balance
497
535
  - `Cd66%` = cached ratio (`cache.read / (input + cache.read)`)
498
536
  - `Est$0.12` = equivalent API cost estimate
499
- - Desktop omits `R/I/O/CR/CW`; TUI keeps the full compact multiline breakdown.
500
- - Order is `base | quota... | usage-summary`, while TUI keeps its multiline usage-first layout.
537
+ - Compact shared titles omit `R/I/O/CR/CW`; the dedicated TUI sidebar keeps the richer `CONTEXT / USAGE / QUOTA` breakdown.
538
+ - Order is `base | quota... | usage-summary` for compact shared titles.
501
539
 
502
540
  `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.
503
541
 
package/dist/format.d.ts CHANGED
@@ -2,6 +2,11 @@ import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
2
2
  import { type UsageSummary } from './usage.js';
3
3
  export type TitleView = 'multiline' | 'compact';
4
4
  export declare const TUI_ACTIVE_MS: number;
5
+ /**
6
+ * Truncate `value` to at most `width` terminal cells.
7
+ * Keep plain text only (no ANSI) to avoid renderer corruption.
8
+ */
9
+ export declare function fitLine(value: string, width: number): string;
5
10
  export declare function isDesktopClient(): boolean;
6
11
  export declare function resolveTitleView(opts: {
7
12
  config: QuotaSidebarConfig;
@@ -23,6 +28,9 @@ export declare function selectDesktopCompactProviderIDs(usage: UsageSummary, con
23
28
  * OpenAI Remaining 78% (only if quota available)
24
29
  */
25
30
  export declare function renderSidebarTitle(baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig, view?: TitleView): string;
31
+ export declare function renderSidebarContextLine(tokens: number, percent: number | undefined, width: number): string;
32
+ export declare function renderSidebarUsageLines(usage: UsageSummary, config: QuotaSidebarConfig): string[];
33
+ export declare function renderSidebarQuotaLines(quotas: QuotaSnapshot[], config: QuotaSidebarConfig): string[];
26
34
  export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
27
35
  showCost?: boolean;
28
36
  }): string;
package/dist/format.js CHANGED
@@ -114,7 +114,7 @@ function truncateToCellWidth(value, width) {
114
114
  * Truncate `value` to at most `width` terminal cells.
115
115
  * Keep plain text only (no ANSI) to avoid renderer corruption.
116
116
  */
117
- function fitLine(value, width) {
117
+ export function fitLine(value, width) {
118
118
  if (width <= 0)
119
119
  return '';
120
120
  const safe = sanitizeLine(value);
@@ -157,6 +157,14 @@ function formatApiCostValue(value) {
157
157
  function formatApiCostLine(value) {
158
158
  return `${formatApiCostValue(value)} as API cost`;
159
159
  }
160
+ function trimTrailingZeroUnit(value) {
161
+ return value
162
+ .replace(/(\d+)\.0(?=[km]\b)/i, '$1')
163
+ .replace(/(\d+)\.0(?=$)/, '$1');
164
+ }
165
+ function panelNumber(value) {
166
+ return trimTrailingZeroUnit(shortNumber(value, 1));
167
+ }
160
168
  function formatRequestsLabel(value, short = false) {
161
169
  const count = shortNumber(value, 1);
162
170
  return short ? `Req ${count}` : `Requests ${count}`;
@@ -171,11 +179,6 @@ export function resolveTitleView(opts) {
171
179
  return 'multiline';
172
180
  if (isDesktopClient())
173
181
  return 'compact';
174
- if (opts.sessionID &&
175
- opts.sessionID === opts.tuiSessionID &&
176
- (opts.now ?? Date.now()) - (opts.tuiActiveAt ?? 0) <= TUI_ACTIVE_MS) {
177
- return 'multiline';
178
- }
179
182
  return 'compact';
180
183
  }
181
184
  function desktopCompactSettings(config) {
@@ -378,6 +381,63 @@ function formatPercent(value, decimals = 1) {
378
381
  const pct = (safe * 100).toFixed(decimals);
379
382
  return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
380
383
  }
384
+ function fitsLine(value, width) {
385
+ return stringCellWidth(sanitizeLine(value)) <= width;
386
+ }
387
+ function usageDetailLines(usage, cacheMetrics, options) {
388
+ const width = options.width;
389
+ const numberToken = options.numberToken || sidebarNumber;
390
+ const costToken = options.costToken || ((value) => `Est${formatApiCostValue(value)}`);
391
+ const groups = [];
392
+ groups.push([
393
+ `R${shortNumber(usage.assistantMessages, 1)}`,
394
+ `I${numberToken(usage.input)}`,
395
+ `O${numberToken(usage.output)}`,
396
+ ]);
397
+ const secondary = [];
398
+ const pushCacheRead = () => {
399
+ if (usage.cacheRead > 0) {
400
+ secondary.push(`CR${numberToken(usage.cacheRead)}`);
401
+ }
402
+ };
403
+ const pushCacheWrite = () => {
404
+ if (usage.cacheWrite > 0) {
405
+ secondary.push(`CW${numberToken(usage.cacheWrite)}`);
406
+ }
407
+ };
408
+ if (options.cacheReadFirst) {
409
+ pushCacheRead();
410
+ pushCacheWrite();
411
+ }
412
+ else {
413
+ pushCacheWrite();
414
+ pushCacheRead();
415
+ }
416
+ if (cacheMetrics.cachedRatio !== undefined) {
417
+ secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
418
+ }
419
+ if (secondary.length > 0)
420
+ groups.push(secondary);
421
+ if (options.showCost && usage.apiCost > 0) {
422
+ groups.push([costToken(usage.apiCost)]);
423
+ }
424
+ const packed = [];
425
+ for (const group of groups) {
426
+ let current = '';
427
+ for (const token of group) {
428
+ const candidate = current ? `${current} ${token}` : token;
429
+ if (!current || fitsLine(candidate, width)) {
430
+ current = candidate;
431
+ continue;
432
+ }
433
+ packed.push(current);
434
+ current = token;
435
+ }
436
+ if (current)
437
+ packed.push(current);
438
+ }
439
+ return packed;
440
+ }
381
441
  function formatQuotaPercent(value, options) {
382
442
  const missing = options?.missing ?? '-';
383
443
  if (value === undefined)
@@ -523,51 +583,43 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config, view) {
523
583
  lines.push(fitLine(line, width));
524
584
  }
525
585
  }
526
- function fitsLine(value, width) {
527
- return stringCellWidth(sanitizeLine(value)) <= width;
528
- }
529
- function usageDetailLines(usage, cacheMetrics, options) {
530
- const width = options.width;
531
- const groups = [];
532
- groups.push([
533
- `R${shortNumber(usage.assistantMessages, 1)}`,
534
- `I${sidebarNumber(usage.input)}`,
535
- `O${sidebarNumber(usage.output)}`,
536
- ]);
537
- const secondary = [];
538
- if (usage.cacheWrite > 0) {
539
- secondary.push(`CW${sidebarNumber(usage.cacheWrite)}`);
540
- }
541
- if (usage.cacheRead > 0) {
542
- secondary.push(`CR${sidebarNumber(usage.cacheRead)}`);
543
- }
544
- if (cacheMetrics.cachedRatio !== undefined) {
545
- secondary.push(`Cd${formatPercent(cacheMetrics.cachedRatio, 0)}`);
546
- }
547
- if (secondary.length > 0)
548
- groups.push(secondary);
549
- if (options.showCost && usage.apiCost > 0) {
550
- groups.push([`Est${formatApiCostValue(usage.apiCost)}`]);
551
- }
552
- const packed = [];
553
- for (const group of groups) {
554
- let current = '';
555
- for (const token of group) {
556
- const candidate = current ? `${current} ${token}` : token;
557
- if (!current || fitsLine(candidate, width)) {
558
- current = candidate;
559
- continue;
560
- }
561
- packed.push(current);
562
- current = token;
563
- }
564
- if (current)
565
- packed.push(current);
566
- }
567
- return packed;
568
- }
569
586
  return lines.join('\n');
570
587
  }
588
+ export function renderSidebarContextLine(tokens, percent, width) {
589
+ const parts = [`${panelNumber(tokens)} tok`];
590
+ if (percent !== undefined && Number.isFinite(percent) && percent >= 0) {
591
+ parts.push(`${Math.round(percent)}% ctx`);
592
+ }
593
+ return fitLine(parts.join(' '), width);
594
+ }
595
+ export function renderSidebarUsageLines(usage, config) {
596
+ const width = Math.max(8, Math.floor(config.sidebar.width || 36));
597
+ const cacheMetrics = getCacheCoverageMetrics(usage);
598
+ return usageDetailLines(usage, cacheMetrics, {
599
+ width,
600
+ showCost: config.sidebar.showCost,
601
+ numberToken: panelNumber,
602
+ costToken: (value) => `Est ${formatApiCostValue(value)}`,
603
+ cacheReadFirst: true,
604
+ }).map((line) => fitLine(line, width));
605
+ }
606
+ export function renderSidebarQuotaLines(quotas, config) {
607
+ const width = Math.max(8, Math.floor(config.sidebar.width || 36));
608
+ const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
609
+ const labelWidth = visibleQuotas.reduce((max, item) => {
610
+ const label = compactProviderLabel(item);
611
+ return Math.max(max, stringCellWidth(label));
612
+ }, 0);
613
+ return visibleQuotas
614
+ .flatMap((item) => 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));
622
+ }
571
623
  /**
572
624
  * Multi-window quota format for sidebar.
573
625
  *
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
- import path from 'node:path';
2
1
  import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, TUI_ACTIVE_MS, } from './format.js';
3
2
  import { createQuotaRuntime } from './quota.js';
4
- import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
3
+ import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
5
4
  import { debug, swallow } from './helpers.js';
6
5
  import { normalizeBaseTitle } from './title.js';
7
6
  import { createDescendantsResolver } from './descendants.js';
@@ -16,16 +15,7 @@ const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
16
15
  const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
17
16
  export async function QuotaSidebarPlugin(input) {
18
17
  const quotaRuntime = createQuotaRuntime();
19
- const configDir = resolveOpencodeConfigDir();
20
- const configOverride = process.env.OPENCODE_QUOTA_CONFIG?.trim();
21
- const config = await loadConfig([
22
- path.join(configDir, 'quota-sidebar.config.json'),
23
- path.join(input.worktree, 'quota-sidebar.config.json'),
24
- path.join(input.directory, 'quota-sidebar.config.json'),
25
- path.join(input.worktree, '.opencode', 'quota-sidebar.config.json'),
26
- path.join(input.directory, '.opencode', 'quota-sidebar.config.json'),
27
- ...(configOverride ? [path.resolve(configOverride)] : []),
28
- ]);
18
+ const config = await loadConfig(quotaConfigPaths(input.worktree, input.directory));
29
19
  const dataDir = resolveOpencodeDataDir();
30
20
  const statePath = stateFilePath(dataDir);
31
21
  const authPath = authFilePath(dataDir);
@@ -224,11 +214,14 @@ export async function QuotaSidebarPlugin(input) {
224
214
  startupTitleWork,
225
215
  new Promise((resolve) => setTimeout(resolve, 5_000)),
226
216
  ]).catch(swallow('shutdown:startupTitleWork'));
227
- await titleRefresh.waitForQuiescence().catch(swallow('shutdown:titleQuiescence'));
217
+ await titleRefresh
218
+ .waitForQuiescence()
219
+ .catch(swallow('shutdown:titleQuiescence'));
228
220
  await flushSave().catch(swallow('shutdown:flushSave'));
229
221
  };
230
222
  const processWithHook = process;
231
- const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||= new Set());
223
+ const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
224
+ new Set());
232
225
  shutdownCallbacks.add(shutdown);
233
226
  if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
234
227
  processWithHook[SHUTDOWN_HOOK_KEY] = true;
@@ -300,7 +293,8 @@ export async function QuotaSidebarPlugin(input) {
300
293
  if (expiryLines.length === 0)
301
294
  return;
302
295
  sessionState.expiryToastShown = true;
303
- const dateKey = state.sessionDateMap[sessionID] || dateKeyFromTimestamp(sessionState.createdAt);
296
+ const dateKey = state.sessionDateMap[sessionID] ||
297
+ dateKeyFromTimestamp(sessionState.createdAt);
304
298
  state.sessionDateMap[sessionID] = dateKey;
305
299
  markDirty(dateKey);
306
300
  scheduleSave();
package/dist/storage.d.ts CHANGED
@@ -2,6 +2,7 @@ import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
2
2
  import { authFilePath, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
3
3
  import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
4
4
  export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
5
+ export declare function quotaConfigPaths(worktree: string, directory: string): string[];
5
6
  export declare const defaultConfig: QuotaSidebarConfig;
6
7
  export declare function defaultState(): QuotaSidebarState;
7
8
  export declare function loadConfig(paths: string[]): Promise<QuotaSidebarConfig>;
package/dist/storage.js CHANGED
@@ -6,6 +6,18 @@ import { dateKeyFromTimestamp, dateKeysInRange, dateStartFromKey, isDateKey, nor
6
6
  import { parseQuotaCache } from './storage_parse.js';
7
7
  import { authFilePath, chunkRootPathFromStateFile, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, } from './storage_paths.js';
8
8
  export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
9
+ export function quotaConfigPaths(worktree, directory) {
10
+ const configDir = resolveOpencodeConfigDir();
11
+ const configOverride = process.env.OPENCODE_QUOTA_CONFIG?.trim();
12
+ return [
13
+ path.join(configDir, 'quota-sidebar.config.json'),
14
+ path.join(worktree, 'quota-sidebar.config.json'),
15
+ path.join(directory, 'quota-sidebar.config.json'),
16
+ path.join(worktree, '.opencode', 'quota-sidebar.config.json'),
17
+ path.join(directory, '.opencode', 'quota-sidebar.config.json'),
18
+ ...(configOverride ? [path.resolve(configOverride)] : []),
19
+ ];
20
+ }
9
21
  // ─── Default config ──────────────────────────────────────────────────────────
10
22
  export const defaultConfig = {
11
23
  sidebar: {
@@ -1,3 +1,3 @@
1
- import type { SessionState } from './types.js';
1
+ import type { QuotaSnapshot, SessionState } from './types.js';
2
2
  export declare function parseSessionState(value: unknown): SessionState | undefined;
3
- export declare function parseQuotaCache(value: unknown): Record<string, import("./types.js").QuotaSnapshot>;
3
+ export declare function parseQuotaCache(value: unknown): Record<string, QuotaSnapshot>;
@@ -105,6 +105,92 @@ function parseCachedUsage(value) {
105
105
  providers,
106
106
  };
107
107
  }
108
+ function parseQuotaSnapshot(value) {
109
+ if (!isRecord(value))
110
+ return undefined;
111
+ const checkedAt = asNumber(value.checkedAt, 0);
112
+ if (!checkedAt)
113
+ return undefined;
114
+ const status = value.status;
115
+ if (status !== 'ok' &&
116
+ status !== 'unavailable' &&
117
+ status !== 'unsupported' &&
118
+ status !== 'error') {
119
+ return undefined;
120
+ }
121
+ const label = typeof value.label === 'string' ? value.label : '';
122
+ const adapterID = typeof value.adapterID === 'string' ? value.adapterID : undefined;
123
+ const shortLabel = typeof value.shortLabel === 'string' ? value.shortLabel : undefined;
124
+ const sortOrder = typeof value.sortOrder === 'number' ? value.sortOrder : undefined;
125
+ const balance = isRecord(value.balance)
126
+ ? {
127
+ amount: typeof value.balance.amount === 'number' ? value.balance.amount : 0,
128
+ currency: typeof value.balance.currency === 'string'
129
+ ? value.balance.currency
130
+ : '$',
131
+ }
132
+ : undefined;
133
+ const windows = Array.isArray(value.windows)
134
+ ? value.windows
135
+ .filter((window) => isRecord(window))
136
+ .map((window) => ({
137
+ label: typeof window.label === 'string' ? window.label : '',
138
+ showPercent: typeof window.showPercent === 'boolean'
139
+ ? window.showPercent
140
+ : undefined,
141
+ resetLabel: typeof window.resetLabel === 'string'
142
+ ? window.resetLabel
143
+ : undefined,
144
+ note: typeof window.note === 'string' ? window.note : undefined,
145
+ remainingPercent: typeof window.remainingPercent === 'number'
146
+ ? window.remainingPercent
147
+ : undefined,
148
+ usedPercent: typeof window.usedPercent === 'number'
149
+ ? window.usedPercent
150
+ : undefined,
151
+ resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
152
+ }))
153
+ .filter((window) => window.label || window.remainingPercent !== undefined)
154
+ : undefined;
155
+ return {
156
+ providerID: typeof value.providerID === 'string' ? value.providerID : label,
157
+ adapterID,
158
+ label,
159
+ shortLabel,
160
+ sortOrder,
161
+ status,
162
+ checkedAt,
163
+ remainingPercent: typeof value.remainingPercent === 'number'
164
+ ? value.remainingPercent
165
+ : undefined,
166
+ usedPercent: typeof value.usedPercent === 'number' ? value.usedPercent : undefined,
167
+ resetAt: typeof value.resetAt === 'string' ? value.resetAt : undefined,
168
+ expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
169
+ balance,
170
+ note: typeof value.note === 'string' ? value.note : undefined,
171
+ windows,
172
+ };
173
+ }
174
+ function parseQuotaSnapshots(value) {
175
+ if (!Array.isArray(value))
176
+ return undefined;
177
+ const parsed = value
178
+ .map((item) => parseQuotaSnapshot(item))
179
+ .filter((item) => Boolean(item));
180
+ return parsed.length > 0 ? parsed : [];
181
+ }
182
+ function parseSidebarPanel(value) {
183
+ if (!isRecord(value))
184
+ return undefined;
185
+ const updatedAt = asNumber(value.updatedAt, 0);
186
+ if (!updatedAt)
187
+ return undefined;
188
+ return {
189
+ updatedAt,
190
+ usage: parseCachedUsage(value.usage),
191
+ quotas: parseQuotaSnapshots(value.quotas),
192
+ };
193
+ }
108
194
  function parseCursor(value) {
109
195
  if (!isRecord(value))
110
196
  return undefined;
@@ -137,75 +223,16 @@ export function parseSessionState(value) {
137
223
  usage: parseCachedUsage(value.usage),
138
224
  dirty: value.dirty === true,
139
225
  cursor: parseCursor(value.cursor),
226
+ sidebarPanel: parseSidebarPanel(value.sidebarPanel),
140
227
  };
141
228
  }
142
229
  export function parseQuotaCache(value) {
143
230
  const raw = isRecord(value) ? value : {};
144
231
  return Object.entries(raw).reduce((acc, [key, item]) => {
145
- if (!isRecord(item))
146
- return acc;
147
- const checkedAt = asNumber(item.checkedAt, 0);
148
- if (!checkedAt)
149
- return acc;
150
- const status = item.status;
151
- if (status !== 'ok' &&
152
- status !== 'unavailable' &&
153
- status !== 'unsupported' &&
154
- status !== 'error') {
232
+ const parsed = parseQuotaSnapshot(item);
233
+ if (!parsed)
155
234
  return acc;
156
- }
157
- const label = typeof item.label === 'string' ? item.label : key;
158
- const adapterID = typeof item.adapterID === 'string' ? item.adapterID : undefined;
159
- const shortLabel = typeof item.shortLabel === 'string' ? item.shortLabel : undefined;
160
- const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : undefined;
161
- const balance = isRecord(item.balance)
162
- ? {
163
- amount: typeof item.balance.amount === 'number' ? item.balance.amount : 0,
164
- currency: typeof item.balance.currency === 'string'
165
- ? item.balance.currency
166
- : '$',
167
- }
168
- : undefined;
169
- const windows = Array.isArray(item.windows)
170
- ? item.windows
171
- .filter((window) => isRecord(window))
172
- .map((window) => ({
173
- label: typeof window.label === 'string' ? window.label : '',
174
- showPercent: typeof window.showPercent === 'boolean'
175
- ? window.showPercent
176
- : undefined,
177
- resetLabel: typeof window.resetLabel === 'string'
178
- ? window.resetLabel
179
- : undefined,
180
- note: typeof window.note === 'string' ? window.note : undefined,
181
- remainingPercent: typeof window.remainingPercent === 'number'
182
- ? window.remainingPercent
183
- : undefined,
184
- usedPercent: typeof window.usedPercent === 'number'
185
- ? window.usedPercent
186
- : undefined,
187
- resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
188
- }))
189
- .filter((window) => window.label || window.remainingPercent !== undefined)
190
- : undefined;
191
- acc[key] = {
192
- providerID: typeof item.providerID === 'string' ? item.providerID : key,
193
- adapterID,
194
- label,
195
- shortLabel,
196
- sortOrder,
197
- status,
198
- checkedAt,
199
- remainingPercent: typeof item.remainingPercent === 'number'
200
- ? item.remainingPercent
201
- : undefined,
202
- usedPercent: typeof item.usedPercent === 'number' ? item.usedPercent : undefined,
203
- resetAt: typeof item.resetAt === 'string' ? item.resetAt : undefined,
204
- expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
205
- balance,
206
- note: typeof item.note === 'string' ? item.note : undefined,
207
- windows,
208
- };
235
+ acc[key] = parsed.label ? parsed : { ...parsed, label: key };
209
236
  return acc;
210
237
  }, {});
211
238
  }
@@ -1,4 +1,5 @@
1
1
  import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
2
+ import { toCachedSessionUsage } from './usage.js';
2
3
  import { swallow, debug, mapConcurrent } from './helpers.js';
3
4
  import { resolveTitleView, selectDesktopCompactProviderIDs, } from './format.js';
4
5
  export function createTitleApplicator(deps) {
@@ -8,6 +9,11 @@ export function createTitleApplicator(deps) {
8
9
  pendingAppliedTitle.delete(sessionID);
9
10
  recentRestore.delete(sessionID);
10
11
  };
12
+ const cloneQuotas = (quotas) => quotas.map((quota) => ({
13
+ ...quota,
14
+ balance: quota.balance ? { ...quota.balance } : undefined,
15
+ windows: quota.windows?.map((win) => ({ ...win })),
16
+ }));
11
17
  const applyTitle = async (sessionID) => {
12
18
  if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
13
19
  return false;
@@ -81,6 +87,12 @@ export function createTitleApplicator(deps) {
81
87
  const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
82
88
  ? await deps.getQuotaSnapshots(quotaProviders)
83
89
  : [];
90
+ sessionState.sidebarPanel = {
91
+ updatedAt: Date.now(),
92
+ usage: toCachedSessionUsage(usage),
93
+ quotas: cloneQuotas(quotas),
94
+ };
95
+ stateMutated = true;
84
96
  const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
85
97
  if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
86
98
  return false;
package/dist/tui.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
2
+ declare const plugin: TuiPluginModule & {
3
+ id: string;
4
+ };
5
+ export default plugin;
package/dist/tui.js ADDED
@@ -0,0 +1,181 @@
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;
package/dist/types.d.ts CHANGED
@@ -109,6 +109,11 @@ export type CachedSessionUsage = {
109
109
  recentProviders?: RecentProviderEvent[];
110
110
  providers: Record<string, CachedProviderUsage>;
111
111
  };
112
+ export type SidebarPanelState = {
113
+ updatedAt: number;
114
+ usage?: CachedSessionUsage;
115
+ quotas?: QuotaSnapshot[];
116
+ };
112
117
  /** Tracks incremental aggregation cursor for a session (P1). */
113
118
  export type IncrementalCursor = {
114
119
  /** ID of the last processed assistant message. */
@@ -129,6 +134,8 @@ export type SessionState = SessionTitleState & {
129
134
  dirty?: boolean;
130
135
  /** Incremental aggregation cursor (P1). */
131
136
  cursor?: IncrementalCursor;
137
+ /** Cached TUI sidebar panel payload for the session. */
138
+ sidebarPanel?: SidebarPanelState;
132
139
  };
133
140
  export type SessionDayChunk = {
134
141
  version: 1;
@@ -150,8 +157,8 @@ export type QuotaSidebarConfig = {
150
157
  enabled: boolean;
151
158
  width: number;
152
159
  /**
153
- * `auto`: compact by default, but keep the actively selected TUI session
154
- * multiline when the plugin can positively identify it.
160
+ * `auto`: keep the shared session title compact and let the dedicated TUI
161
+ * sidebar plugin render the rich panel layout.
155
162
  */
156
163
  titleMode?: SidebarTitleMode;
157
164
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.23",
3
+ "version": "2.0.26",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,8 +9,16 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
+ },
13
+ "./tui": {
14
+ "types": "./dist/tui.d.ts",
15
+ "default": "./dist/tui.js"
12
16
  }
13
17
  },
18
+ "oc-plugin": [
19
+ "server",
20
+ "tui"
21
+ ],
14
22
  "files": [
15
23
  "dist/*.js",
16
24
  "dist/*.d.ts",
@@ -56,12 +64,17 @@
56
64
  "node": ">=18"
57
65
  },
58
66
  "peerDependencies": {
59
- "@opencode-ai/plugin": "^1.2.10",
60
- "@opencode-ai/sdk": "^1.2.10"
67
+ "@opencode-ai/plugin": "^1.3.5",
68
+ "@opencode-ai/sdk": "^1.3.5"
69
+ },
70
+ "dependencies": {
71
+ "@opentui/core": "^0.1.92",
72
+ "@opentui/solid": "^0.1.92",
73
+ "solid-js": "^1.9.10"
61
74
  },
62
75
  "devDependencies": {
63
- "@opencode-ai/plugin": "^1.2.10",
64
- "@opencode-ai/sdk": "^1.2.10",
76
+ "@opencode-ai/plugin": "^1.3.5",
77
+ "@opencode-ai/sdk": "^1.3.5",
65
78
  "@types/node": "^22.13.10",
66
79
  "typescript": "^5.8.2"
67
80
  }