@leo000001/opencode-quota-sidebar 2.0.23 → 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.
@@ -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
  /**
@@ -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,96 @@ 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 version = asNumber(value.version, 1);
186
+ if (version !== 1)
187
+ return undefined;
188
+ const updatedAt = asNumber(value.updatedAt, 0);
189
+ if (!updatedAt)
190
+ return undefined;
191
+ return {
192
+ version: 1,
193
+ updatedAt,
194
+ usage: parseCachedUsage(value.usage),
195
+ quotas: parseQuotaSnapshots(value.quotas),
196
+ };
197
+ }
108
198
  function parseCursor(value) {
109
199
  if (!isRecord(value))
110
200
  return undefined;
@@ -137,75 +227,16 @@ export function parseSessionState(value) {
137
227
  usage: parseCachedUsage(value.usage),
138
228
  dirty: value.dirty === true,
139
229
  cursor: parseCursor(value.cursor),
230
+ sidebarPanel: parseSidebarPanel(value.sidebarPanel),
140
231
  };
141
232
  }
142
233
  export function parseQuotaCache(value) {
143
234
  const raw = isRecord(value) ? value : {};
144
235
  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') {
236
+ const parsed = parseQuotaSnapshot(item);
237
+ if (!parsed)
155
238
  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
- };
239
+ acc[key] = parsed.label ? parsed : { ...parsed, label: key };
209
240
  return acc;
210
241
  }, {});
211
242
  }
@@ -1,6 +1,8 @@
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';
5
+ import { collapseQuotaSnapshots } from './quota_render.js';
4
6
  export function createTitleApplicator(deps) {
5
7
  const pendingAppliedTitle = new Map();
6
8
  const recentRestore = new Map();
@@ -8,8 +10,13 @@ export function createTitleApplicator(deps) {
8
10
  pendingAppliedTitle.delete(sessionID);
9
11
  recentRestore.delete(sessionID);
10
12
  };
13
+ const cloneQuotas = (quotas) => quotas.map((quota) => ({
14
+ ...quota,
15
+ balance: quota.balance ? { ...quota.balance } : undefined,
16
+ windows: quota.windows?.map((win) => ({ ...win })),
17
+ }));
11
18
  const applyTitle = async (sessionID) => {
12
- if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
19
+ if (!deps.config.sidebar.enabled)
13
20
  return false;
14
21
  let stateMutated = false;
15
22
  const session = await deps.client.session
@@ -74,16 +81,29 @@ export function createTitleApplicator(deps) {
74
81
  }
75
82
  const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
76
83
  const view = deps.getTitleView?.(sessionID) ??
77
- resolveTitleView({ config: deps.config, sessionID });
84
+ resolveTitleView({ config: deps.config });
78
85
  const quotaProviders = Array.from(new Set(view === 'compact'
79
86
  ? selectDesktopCompactProviderIDs(usage, deps.config)
80
87
  : Object.keys(usage.providers)));
81
88
  const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
82
89
  ? await deps.getQuotaSnapshots(quotaProviders)
83
90
  : [];
84
- const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
85
- if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
91
+ sessionState.sidebarPanel = {
92
+ version: 1,
93
+ updatedAt: Date.now(),
94
+ usage: toCachedSessionUsage(usage),
95
+ quotas: cloneQuotas(collapseQuotaSnapshots(quotas)),
96
+ };
97
+ stateMutated = true;
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);
86
104
  return false;
105
+ }
106
+ const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
87
107
  if (canonicalizeTitleForCompare(nextTitle) ===
88
108
  canonicalizeTitleForCompare(session.data.title)) {
89
109
  if (looksDecorated(session.data.title)) {
package/dist/tui.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
3
+ declare const plugin: TuiPluginModule & {
4
+ id: string;
5
+ };
6
+ export default plugin;
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