@leo000001/opencode-quota-sidebar 2.0.26 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,7 +23,9 @@ 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[];
34
30
  export declare function renderMarkdownReport(period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
35
31
  showCost?: boolean;
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,12 +587,12 @@ 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,
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,363 @@
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 {
10
+ fitLine,
11
+ renderSidebarQuotaLines,
12
+ renderSidebarUsageLines,
13
+ } from './format.js'
14
+ import {
15
+ loadConfig,
16
+ loadState,
17
+ quotaConfigPaths,
18
+ resolveOpencodeDataDir,
19
+ stateFilePath,
20
+ } from './storage.js'
21
+ import { looksDecorated, normalizeBaseTitle } from './title.js'
22
+ import type { QuotaSidebarConfig } from './types.js'
23
+ import { fromCachedSessionUsage, summarizeMessages } from './usage.js'
24
+
25
+ const id = 'leo.quota-sidebar'
26
+ const INTERNAL_CONTEXT_PLUGIN_ID = 'internal:sidebar-context'
27
+ const SECTION_INDENT = 2
28
+ const DEFAULT_WIDTH = 36
29
+
30
+ type SidebarPanelData = {
31
+ enabled: boolean
32
+ width: number
33
+ usageLines: string[]
34
+ quotaLines: string[]
35
+ compactTitle?: string
36
+ }
37
+
38
+ const latestCompactTitles = new Map<string, string>()
39
+ const [compactTitleVersion, setCompactTitleVersion] = createSignal(0)
40
+
41
+ function directoryPath(api: TuiPluginApi) {
42
+ return api.state.path.directory || process.cwd()
43
+ }
44
+
45
+ function worktreePath(api: TuiPluginApi) {
46
+ return api.state.path.worktree || directoryPath(api)
47
+ }
48
+
49
+ function panelConfig(config: QuotaSidebarConfig): QuotaSidebarConfig {
50
+ return {
51
+ ...config,
52
+ sidebar: {
53
+ ...config.sidebar,
54
+ width: Math.max(8, config.sidebar.width - SECTION_INDENT),
55
+ },
56
+ }
57
+ }
58
+
59
+ function resolveCompactTitle(sessionID: string, persistedTitle?: string) {
60
+ const liveTitle = latestCompactTitles.get(sessionID)
61
+ if (liveTitle && looksDecorated(liveTitle)) return liveTitle
62
+ if (persistedTitle && looksDecorated(persistedTitle)) return persistedTitle
63
+ return liveTitle || persistedTitle
64
+ }
65
+
66
+ async function loadSidebarPanel(
67
+ api: TuiPluginApi,
68
+ sessionID: string,
69
+ ): Promise<SidebarPanelData> {
70
+ const statePath = stateFilePath(resolveOpencodeDataDir())
71
+ const config = await loadConfig(
72
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
73
+ )
74
+ // Session payload lives in day chunks that the server updates from a
75
+ // separate process, so TUI should re-read persisted state instead of keeping
76
+ // an extra full-state cache here.
77
+ const state = await loadState(statePath)
78
+ const session = state.sessions[sessionID]
79
+ const enabled = config.sidebar.enabled
80
+ const width = Math.max(8, config.sidebar.width - SECTION_INDENT)
81
+ const liveEntries = api.state.session.messages(sessionID).map((info) => ({
82
+ info,
83
+ })) as Parameters<typeof summarizeMessages>[0]
84
+
85
+ const liveUsage = summarizeMessages(liveEntries, 0, 1)
86
+ const cachedUsage = session?.sidebarPanel?.usage || session?.usage
87
+ const usage = cachedUsage
88
+ ? fromCachedSessionUsage(cachedUsage)
89
+ : liveUsage.assistantMessages > 0
90
+ ? liveUsage
91
+ : undefined
92
+ const compactTitle = resolveCompactTitle(sessionID, session?.lastAppliedTitle)
93
+
94
+ if (!enabled) {
95
+ return {
96
+ enabled,
97
+ width,
98
+ usageLines: [],
99
+ quotaLines: [],
100
+ compactTitle: session?.lastAppliedTitle,
101
+ }
102
+ }
103
+
104
+ const usageLines = usage
105
+ ? renderSidebarUsageLines(usage, panelConfig(config))
106
+ : []
107
+ const quotaLines = renderSidebarQuotaLines(
108
+ session?.sidebarPanel?.quotas || [],
109
+ panelConfig(config),
110
+ )
111
+
112
+ return {
113
+ enabled,
114
+ width,
115
+ usageLines,
116
+ quotaLines,
117
+ compactTitle,
118
+ }
119
+ }
120
+
121
+ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
122
+ const [panel, setPanel] = createSignal<SidebarPanelData | undefined>()
123
+ let disposed = false
124
+ let loadVersion = 0
125
+
126
+ const reload = () => {
127
+ const currentVersion = ++loadVersion
128
+ const currentSessionID = sessionID()
129
+ void loadSidebarPanel(api, currentSessionID)
130
+ .then((next) => {
131
+ if (disposed || currentVersion !== loadVersion) return
132
+ setPanel(next)
133
+ })
134
+ .catch((error) => {
135
+ if (disposed || currentVersion !== loadVersion) return
136
+ void error
137
+ })
138
+ }
139
+
140
+ reload()
141
+
142
+ const timers = new Set<ReturnType<typeof setTimeout>>()
143
+ const queueRefresh = (delay = 250) => {
144
+ const timer = setTimeout(() => {
145
+ timers.delete(timer)
146
+ reload()
147
+ }, delay)
148
+ timers.add(timer)
149
+ }
150
+
151
+ const scheduleRefresh = () => {
152
+ queueRefresh(300)
153
+ queueRefresh(1_000)
154
+ }
155
+
156
+ // Bulk session sync populates messages asynchronously without emitting the
157
+ // real-time message.updated events we listen to below. Retry a few times on
158
+ // mount so historical sessions can render usage once the sync finishes.
159
+ queueRefresh(500)
160
+ queueRefresh(1_500)
161
+ queueRefresh(4_000)
162
+
163
+ const unsubscribers = [
164
+ api.event.on('session.updated', (event) => {
165
+ if (event.properties.info.id === sessionID()) {
166
+ scheduleRefresh()
167
+ }
168
+ }),
169
+ api.event.on('message.updated', (event) => {
170
+ if (event.properties.info.sessionID === sessionID()) {
171
+ scheduleRefresh()
172
+ }
173
+ }),
174
+ api.event.on('message.removed', (event) => {
175
+ if (event.properties.sessionID === sessionID()) {
176
+ scheduleRefresh()
177
+ }
178
+ }),
179
+ api.event.on('tui.session.select', (event) => {
180
+ if (event.properties.sessionID === sessionID()) {
181
+ scheduleRefresh()
182
+ }
183
+ }),
184
+ ]
185
+
186
+ onCleanup(() => {
187
+ disposed = true
188
+ for (const timer of timers) clearTimeout(timer)
189
+ timers.clear()
190
+ for (const unsubscribe of unsubscribers) unsubscribe()
191
+ })
192
+
193
+ return panel
194
+ }
195
+
196
+ function sectionHeading(api: TuiPluginApi, value: string) {
197
+ return <text fg={api.theme.current.textMuted}>{value}</text>
198
+ }
199
+
200
+ function fallbackQuotaLinesFromTitle(title: string, width: number) {
201
+ const parts = (title || '')
202
+ .split(' | ')
203
+ .map((part) => part.trim())
204
+ .filter(Boolean)
205
+ if (parts.length <= 1) return [] as string[]
206
+ return parts
207
+ .slice(1)
208
+ .filter((part) => !/^Cd\d/.test(part) && !/^Est\b/.test(part))
209
+ .map((part) => fitLine(part, width))
210
+ }
211
+
212
+ function fallbackUsageCostLineFromTitle(title: string, width: number) {
213
+ const est = (title || '')
214
+ .split(' | ')
215
+ .map((part) => part.trim())
216
+ .find((part) => /^Est\$/.test(part) || /^Est\s+\$/.test(part))
217
+ if (!est) return undefined
218
+ return fitLine(est.replace(/^Est\$/, 'Est $'), width)
219
+ }
220
+
221
+ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
222
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
223
+ const width = createMemo(
224
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
225
+ )
226
+ const compactTitle = createMemo(() => {
227
+ compactTitleVersion()
228
+ return resolveCompactTitle(props.sessionID, panel()?.compactTitle) || ''
229
+ })
230
+ const usageLines = createMemo(() => {
231
+ const liveLines = panel()?.usageLines || []
232
+ const hasCostLine = liveLines.some((line) => /^Est\b/.test(line))
233
+ if (hasCostLine) return liveLines
234
+ const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
235
+ return costLine ? [...liveLines, costLine] : liveLines
236
+ })
237
+ const quotaLines = createMemo(() => {
238
+ const liveLines = panel()?.quotaLines || []
239
+ if (liveLines.length > 0) return liveLines
240
+ return fallbackQuotaLinesFromTitle(compactTitle(), width())
241
+ })
242
+ const hasUsage = createMemo(() => usageLines().length > 0)
243
+ const hasQuota = createMemo(() => quotaLines().length > 0)
244
+
245
+ return (
246
+ <box gap={0}>
247
+ <Show when={hasUsage()}>
248
+ <box gap={0}>
249
+ {sectionHeading(props.api, 'USAGE')}
250
+ <box gap={0}>
251
+ <For each={usageLines()}>
252
+ {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
253
+ </For>
254
+ </box>
255
+ </box>
256
+ </Show>
257
+
258
+ <Show when={hasQuota()}>
259
+ <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
260
+ {sectionHeading(props.api, 'QUOTA')}
261
+ <box gap={0}>
262
+ <For each={quotaLines()}>
263
+ {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
264
+ </For>
265
+ </box>
266
+ </box>
267
+ </Show>
268
+ </box>
269
+ )
270
+ }
271
+
272
+ function SidebarTitleView(props: {
273
+ api: TuiPluginApi
274
+ sessionID: string
275
+ title: string
276
+ shareURL?: string
277
+ }) {
278
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
279
+ const width = createMemo(
280
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
281
+ )
282
+ const titleLines = createMemo(() => {
283
+ const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session'
284
+ return baseTitle
285
+ .split(/\r?\n/)
286
+ .filter(Boolean)
287
+ .map((line) => fitLine(line, width()))
288
+ })
289
+ const shareLine = createMemo(() =>
290
+ props.shareURL ? fitLine(props.shareURL, width()) : undefined,
291
+ )
292
+
293
+ return (
294
+ <box gap={0} paddingRight={1}>
295
+ {sectionHeading(props.api, 'TITLE')}
296
+ <box gap={0}>
297
+ <For each={titleLines()}>
298
+ {(line) => <text fg={props.api.theme.current.text}>{line}</text>}
299
+ </For>
300
+ <Show when={shareLine()}>
301
+ <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
302
+ </Show>
303
+ </box>
304
+ </box>
305
+ )
306
+ }
307
+
308
+ const tui: TuiPlugin = async (api) => {
309
+ const config = await loadConfig(
310
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
311
+ )
312
+ let didDeactivateContext = false
313
+ if (config.sidebar.enabled) {
314
+ const contextPlugin = api.plugins
315
+ .list()
316
+ .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID)
317
+ if (contextPlugin?.active) {
318
+ didDeactivateContext = await api.plugins
319
+ .deactivate(INTERNAL_CONTEXT_PLUGIN_ID)
320
+ .catch(() => false)
321
+ }
322
+ }
323
+ api.lifecycle.onDispose(() => {
324
+ if (!didDeactivateContext) return
325
+ return api.plugins
326
+ .activate(INTERNAL_CONTEXT_PLUGIN_ID)
327
+ .then(() => undefined)
328
+ .catch(() => undefined)
329
+ })
330
+
331
+ api.slots.register({
332
+ order: 100,
333
+ slots: {
334
+ sidebar_title(
335
+ _ctx: unknown,
336
+ props: { session_id: string; title: string; share_url?: string },
337
+ ) {
338
+ if (latestCompactTitles.get(props.session_id) !== props.title) {
339
+ latestCompactTitles.set(props.session_id, props.title)
340
+ setCompactTitleVersion((value) => value + 1)
341
+ }
342
+ return (
343
+ <SidebarTitleView
344
+ api={api}
345
+ sessionID={props.session_id}
346
+ title={props.title}
347
+ shareURL={props.share_url}
348
+ />
349
+ )
350
+ },
351
+ sidebar_content(_ctx: unknown, props: { session_id: string }) {
352
+ return <SidebarContentView api={api} sessionID={props.session_id} />
353
+ },
354
+ },
355
+ })
356
+ }
357
+
358
+ const plugin: TuiPluginModule & { id: string } = {
359
+ id,
360
+ tui,
361
+ }
362
+
363
+ export default plugin
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.0",
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;