@leo000001/opencode-quota-sidebar 4.0.5 → 4.0.11

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/dist/tui.tsx CHANGED
@@ -1,471 +1,481 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type {
3
- TuiPlugin,
4
- TuiPluginApi,
5
- TuiPluginModule,
6
- } from '@opencode-ai/plugin/tui'
7
- import { createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
8
-
9
- import { fitLine, renderSidebarUsageLines } from './format.js'
10
- import {
11
- fallbackQuotaGroupsFromTitle,
12
- quotaGroupsAreCollapsible,
13
- quotaGroupsSummary,
14
- quotaGroupsUseBullets,
15
- renderSidebarQuotaGroups,
16
- sidebarPanelQuotaSnapshots,
17
- type SidebarQuotaGroup,
18
- } from './tui_helpers.js'
19
- import {
20
- loadConfig,
21
- loadState,
22
- quotaConfigPaths,
23
- resolveOpencodeDataDir,
24
- stateFilePath,
25
- } from './storage.js'
26
- import { looksDecorated, normalizeBaseTitle } from './title.js'
27
- import type { QuotaSidebarConfig } from './types.js'
28
- import {
29
- fromCachedSessionUsage,
30
- mergeUsage,
31
- summarizeMessages,
32
- } from './usage.js'
33
-
34
- const id = 'leo.quota-sidebar'
35
- const INTERNAL_CONTEXT_PLUGIN_ID = 'internal:sidebar-context'
36
- const SECTION_INDENT = 2
37
- const DEFAULT_WIDTH = 36
38
-
39
- type SidebarPanelData = {
40
- enabled: boolean
41
- width: number
42
- usageLines: string[]
43
- quotaGroups: SidebarQuotaGroup[]
44
- compactTitle?: string
45
- }
46
-
47
- const latestCompactTitles = new Map<string, string>()
48
- const [compactTitleVersion, setCompactTitleVersion] = createSignal(0)
49
-
50
- function directoryPath(api: TuiPluginApi) {
51
- return api.state.path.directory || process.cwd()
52
- }
53
-
54
- function worktreePath(api: TuiPluginApi) {
55
- return api.state.path.worktree || directoryPath(api)
56
- }
57
-
58
- function panelConfig(config: QuotaSidebarConfig): QuotaSidebarConfig {
59
- return {
60
- ...config,
61
- sidebar: {
62
- ...config.sidebar,
63
- width: Math.max(8, config.sidebar.width - SECTION_INDENT),
64
- },
65
- }
66
- }
67
-
68
- function resolveCompactTitle(sessionID: string, persistedTitle?: string) {
69
- const liveTitle = latestCompactTitles.get(sessionID)
70
- if (liveTitle && looksDecorated(liveTitle)) return liveTitle
71
- if (persistedTitle && looksDecorated(persistedTitle)) return persistedTitle
72
- return liveTitle || persistedTitle
73
- }
74
-
75
- async function loadSidebarPanel(
76
- api: TuiPluginApi,
77
- sessionID: string,
78
- ): Promise<SidebarPanelData> {
79
- const statePath = stateFilePath(resolveOpencodeDataDir())
80
- const config = await loadConfig(
81
- quotaConfigPaths(worktreePath(api), directoryPath(api)),
82
- )
83
- // Session payload lives in day chunks that the server updates from a
84
- // separate process, so TUI should re-read persisted state instead of keeping
85
- // an extra full-state cache here.
86
- const state = await loadState(statePath)
87
- const session = state.sessions[sessionID]
88
- const enabled = config.sidebar.enabled
89
- const width = Math.max(8, config.sidebar.width - SECTION_INDENT)
90
- const liveEntries = api.state.session.messages(sessionID).map((info) => ({
91
- info,
92
- })) as Parameters<typeof summarizeMessages>[0]
93
-
94
- const liveUsage = summarizeMessages(liveEntries, 0, 1)
95
- const cachedUsage = session?.sidebarPanel?.usage || session?.usage
96
- const persistedUsage = cachedUsage
97
- ? fromCachedSessionUsage(cachedUsage)
98
- : undefined
99
- const usage =
100
- liveUsage.assistantMessages > 0 &&
101
- (!persistedUsage ||
102
- liveUsage.assistantMessages > persistedUsage.assistantMessages ||
103
- (liveUsage.assistantMessages === persistedUsage.assistantMessages &&
104
- liveUsage.total >= persistedUsage.total))
105
- ? liveUsage
106
- : persistedUsage ||
107
- (liveUsage.assistantMessages > 0 ? liveUsage : undefined)
108
- const compactTitle = resolveCompactTitle(sessionID, session?.lastAppliedTitle)
109
-
110
- if (!enabled) {
111
- return {
112
- enabled,
113
- width,
114
- usageLines: [],
115
- quotaGroups: [],
116
- compactTitle: session?.lastAppliedTitle,
117
- }
118
- }
119
-
120
- const usageLines = usage
121
- ? renderSidebarUsageLines(usage, panelConfig(config))
122
- : []
123
- const quotaGroups = renderSidebarQuotaGroups(
124
- sidebarPanelQuotaSnapshots(session?.sidebarPanel),
125
- panelConfig(config),
126
- )
127
-
128
- return {
129
- enabled,
130
- width,
131
- usageLines,
132
- quotaGroups,
133
- compactTitle,
134
- }
135
- }
136
-
137
- function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
138
- const [panel, setPanel] = createSignal<SidebarPanelData | undefined>()
139
- let disposed = false
140
- let loadVersion = 0
141
-
142
- const reload = () => {
143
- const currentVersion = ++loadVersion
144
- const currentSessionID = sessionID()
145
- void loadSidebarPanel(api, currentSessionID)
146
- .then((next) => {
147
- if (disposed || currentVersion !== loadVersion) return
148
- setPanel(next)
149
- })
150
- .catch((error) => {
151
- if (disposed || currentVersion !== loadVersion) return
152
- void error
153
- })
154
- }
155
-
156
- reload()
157
-
158
- const timers = new Set<ReturnType<typeof setTimeout>>()
159
- const queueRefresh = (delay = 250) => {
160
- const timer = setTimeout(() => {
161
- timers.delete(timer)
162
- reload()
163
- }, delay)
164
- timers.add(timer)
165
- }
166
-
167
- const scheduleRefresh = () => {
168
- queueRefresh(150)
169
- queueRefresh(600)
170
- }
171
-
172
- // Bulk session sync populates messages asynchronously without emitting the
173
- // real-time message.updated events we listen to below. Retry a few times on
174
- // mount so historical sessions can render usage once the sync finishes.
175
- queueRefresh(500)
176
- queueRefresh(1_500)
177
- queueRefresh(4_000)
178
-
179
- const unsubscribers = [
180
- api.event.on('session.updated', (event) => {
181
- if (event.properties.info.id === sessionID()) {
182
- scheduleRefresh()
183
- }
184
- }),
185
- api.event.on('message.updated', (event) => {
186
- if (event.properties.info.sessionID === sessionID()) {
187
- scheduleRefresh()
188
- }
189
- }),
190
- api.event.on('message.removed', (event) => {
191
- if (event.properties.sessionID === sessionID()) {
192
- scheduleRefresh()
193
- }
194
- }),
195
- api.event.on('tui.session.select', (event) => {
196
- if (event.properties.sessionID === sessionID()) {
197
- scheduleRefresh()
198
- }
199
- }),
200
- ]
201
-
202
- onCleanup(() => {
203
- disposed = true
204
- for (const timer of timers) clearTimeout(timer)
205
- timers.clear()
206
- for (const unsubscribe of unsubscribers) unsubscribe()
207
- })
208
-
209
- return panel
210
- }
211
-
212
- function SectionHeading(props: {
213
- api: TuiPluginApi
214
- value: string
215
- collapsible?: boolean
216
- open?: boolean
217
- summary?: string
218
- onToggle?: () => void
219
- }) {
220
- const clickable = () => props.collapsible === true && props.onToggle
221
-
222
- return (
223
- <box
224
- flexDirection="row"
225
- gap={1}
226
- onMouseDown={() => {
227
- if (!clickable()) return
228
- props.onToggle?.()
229
- }}
230
- >
231
- <Show when={props.collapsible}>
232
- <text fg={props.api.theme.current.text}>{props.open ? '▼' : '▶'}</text>
233
- </Show>
234
- <text fg={props.api.theme.current.text}>
235
- <b>{props.value}</b>
236
- <Show when={props.summary}>
237
- <span style={{ fg: props.api.theme.current.textMuted }}>
238
- {' '}
239
- {props.summary}
240
- </span>
241
- </Show>
242
- </text>
243
- </box>
244
- )
245
- }
246
-
247
- function quotaToneColor(api: TuiPluginApi, tone: SidebarQuotaGroup['tone']) {
248
- const theme = api.theme.current
249
- if (tone === 'success') return theme.success
250
- if (tone === 'warning') return theme.warning
251
- if (tone === 'error') return theme.error
252
- return theme.textMuted
253
- }
254
-
255
- function QuotaGroupBlock(props: {
256
- api: TuiPluginApi
257
- group: SidebarQuotaGroup
258
- bullet: boolean
259
- }) {
260
- const content = (
261
- <box gap={0}>
262
- <text>
263
- <span style={{ fg: props.api.theme.current.text }}>
264
- {props.group.shortLabel}
265
- </span>
266
- <Show when={props.group.detail}>
267
- <span style={{ fg: props.api.theme.current.textMuted }}>
268
- {' '}
269
- {props.group.detail}
270
- </span>
271
- </Show>
272
- </text>
273
- <For each={props.group.continuationLines}>
274
- {(line) => <text fg={props.api.theme.current.textMuted}>{line}</text>}
275
- </For>
276
- </box>
277
- )
278
-
279
- return (
280
- <Show when={props.bullet} fallback={content}>
281
- <box flexDirection="row" gap={1}>
282
- <text flexShrink={0} fg={quotaToneColor(props.api, props.group.tone)}>
283
-
284
- </text>
285
- {content}
286
- </box>
287
- </Show>
288
- )
289
- }
290
-
291
- function fallbackUsageCostLineFromTitle(title: string, width: number) {
292
- const est = (title || '')
293
- .split(' | ')
294
- .map((part) => part.trim())
295
- .find((part) => /^Est\$/.test(part) || /^Est\s+\$/.test(part))
296
- if (!est) return undefined
297
- return fitLine(est.replace(/^Est\$/, 'Est $'), width)
298
- }
299
-
300
- function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
301
- const panel = useSidebarPanelData(props.api, () => props.sessionID)
302
- const [quotaOpen, setQuotaOpen] = createSignal(true)
303
- const width = createMemo(
304
- () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
305
- )
306
- const compactTitle = createMemo(() => {
307
- compactTitleVersion()
308
- return resolveCompactTitle(props.sessionID, panel()?.compactTitle) || ''
309
- })
310
- const usageLines = createMemo(() => {
311
- const liveLines = panel()?.usageLines || []
312
- const hasCostLine = liveLines.some((line) => /^Est\b/.test(line))
313
- if (hasCostLine) return liveLines
314
- const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
315
- return costLine ? [...liveLines, costLine] : liveLines
316
- })
317
- const quotaGroups = createMemo(() => {
318
- const liveGroups = panel()?.quotaGroups || []
319
- if (liveGroups.length > 0) return liveGroups
320
- return fallbackQuotaGroupsFromTitle(compactTitle(), width())
321
- })
322
- const hasUsage = createMemo(() => usageLines().length > 0)
323
- const hasQuota = createMemo(() => quotaGroups().length > 0)
324
- const quotaBullets = createMemo(() => quotaGroupsUseBullets(quotaGroups()))
325
- const quotaCollapsible = createMemo(() =>
326
- quotaGroupsAreCollapsible(quotaGroups()),
327
- )
328
- const quotaSummary = createMemo(() => {
329
- if (!quotaCollapsible() || quotaOpen()) return undefined
330
- return quotaGroupsSummary(quotaGroups())
331
- })
332
-
333
- return (
334
- <box gap={0}>
335
- <Show when={hasUsage()}>
336
- <box gap={0}>
337
- <SectionHeading api={props.api} value="Usage" />
338
- <box gap={0}>
339
- <For each={usageLines()}>
340
- {(line) => (
341
- <text fg={props.api.theme.current.textMuted}>{line}</text>
342
- )}
343
- </For>
344
- </box>
345
- </box>
346
- </Show>
347
-
348
- <Show when={hasQuota()}>
349
- <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
350
- <SectionHeading
351
- api={props.api}
352
- value="Quota"
353
- collapsible={quotaCollapsible()}
354
- open={quotaOpen()}
355
- summary={quotaSummary()}
356
- onToggle={() => setQuotaOpen((value) => !value)}
357
- />
358
- <Show when={!quotaCollapsible() || quotaOpen()}>
359
- <box gap={0}>
360
- <For each={quotaGroups()}>
361
- {(group) => (
362
- <QuotaGroupBlock
363
- api={props.api}
364
- group={group}
365
- bullet={quotaBullets()}
366
- />
367
- )}
368
- </For>
369
- </box>
370
- </Show>
371
- </box>
372
- </Show>
373
- </box>
374
- )
375
- }
376
-
377
- function SidebarTitleView(props: {
378
- api: TuiPluginApi
379
- sessionID: string
380
- title: string
381
- shareURL?: string
382
- }) {
383
- const panel = useSidebarPanelData(props.api, () => props.sessionID)
384
- const width = createMemo(
385
- () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
386
- )
387
- const titleLines = createMemo(() => {
388
- const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session'
389
- return baseTitle
390
- .split(/\r?\n/)
391
- .filter(Boolean)
392
- .map((line) => fitLine(line, width()))
393
- })
394
- const shareLine = createMemo(() =>
395
- props.shareURL ? fitLine(props.shareURL, width()) : undefined,
396
- )
397
-
398
- return (
399
- <box gap={0} paddingRight={1}>
400
- <box gap={0}>
401
- <For each={titleLines()}>
402
- {(line) => (
403
- <text fg={props.api.theme.current.text}>
404
- <b>{line}</b>
405
- </text>
406
- )}
407
- </For>
408
- <Show when={shareLine()}>
409
- <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
410
- </Show>
411
- </box>
412
- </box>
413
- )
414
- }
415
-
416
- const tui: TuiPlugin = async (api) => {
417
- const config = await loadConfig(
418
- quotaConfigPaths(worktreePath(api), directoryPath(api)),
419
- )
420
- let didDeactivateContext = false
421
- if (config.sidebar.enabled) {
422
- const contextPlugin = api.plugins
423
- .list()
424
- .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID)
425
- if (contextPlugin?.active) {
426
- didDeactivateContext = await api.plugins
427
- .deactivate(INTERNAL_CONTEXT_PLUGIN_ID)
428
- .catch(() => false)
429
- }
430
- }
431
- api.lifecycle.onDispose(() => {
432
- if (!didDeactivateContext) return
433
- return api.plugins
434
- .activate(INTERNAL_CONTEXT_PLUGIN_ID)
435
- .then(() => undefined)
436
- .catch(() => undefined)
437
- })
438
-
439
- api.slots.register({
440
- order: 100,
441
- slots: {
442
- sidebar_title(
443
- _ctx: unknown,
444
- props: { session_id: string; title: string; share_url?: string },
445
- ) {
446
- if (latestCompactTitles.get(props.session_id) !== props.title) {
447
- latestCompactTitles.set(props.session_id, props.title)
448
- setCompactTitleVersion((value) => value + 1)
449
- }
450
- return (
451
- <SidebarTitleView
452
- api={api}
453
- sessionID={props.session_id}
454
- title={props.title}
455
- shareURL={props.share_url}
456
- />
457
- )
458
- },
459
- sidebar_content(_ctx: unknown, props: { session_id: string }) {
460
- return <SidebarContentView api={api} sessionID={props.session_id} />
461
- },
462
- },
463
- })
464
- }
465
-
466
- const plugin: TuiPluginModule & { id: string } = {
467
- id,
468
- tui,
469
- }
470
-
471
- export default plugin
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type {
3
+ TuiPlugin,
4
+ TuiPluginApi,
5
+ TuiPluginModule,
6
+ } from "@opencode-ai/plugin/tui";
7
+ import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
8
+
9
+ import { fitLine, renderSidebarUsageLines } from "./format.js";
10
+ import {
11
+ fallbackQuotaGroupsFromTitle,
12
+ mergeLiveAndPersistedPanelUsage,
13
+ quotaGroupsAreCollapsible,
14
+ quotaGroupsSummary,
15
+ quotaGroupsUseBullets,
16
+ renderSidebarQuotaGroups,
17
+ sidebarPanelQuotaSnapshots,
18
+ type SidebarQuotaGroup,
19
+ } from "./tui_helpers.js";
20
+ import {
21
+ loadConfig,
22
+ loadState,
23
+ quotaConfigPaths,
24
+ resolveOpencodeDataDir,
25
+ stateFilePath,
26
+ } from "./storage.js";
27
+ import { looksDecorated, normalizeBaseTitle } from "./title.js";
28
+ import type { QuotaSidebarConfig } from "./types.js";
29
+ import {
30
+ fromCachedSessionUsage,
31
+ mergeUsage,
32
+ summarizeMessages,
33
+ } from "./usage.js";
34
+
35
+ const id = "leo.quota-sidebar";
36
+ const INTERNAL_CONTEXT_PLUGIN_ID = "internal:sidebar-context";
37
+ const SECTION_INDENT = 2;
38
+ const DEFAULT_WIDTH = 36;
39
+
40
+ type SidebarPanelData = {
41
+ enabled: boolean;
42
+ width: number;
43
+ usageLines: string[];
44
+ quotaGroups: SidebarQuotaGroup[];
45
+ compactTitle?: string;
46
+ };
47
+
48
+ const latestCompactTitles = new Map<string, string>();
49
+ const [compactTitleVersion, setCompactTitleVersion] = createSignal(0);
50
+
51
+ function directoryPath(api: TuiPluginApi) {
52
+ return api.state.path.directory || process.cwd();
53
+ }
54
+
55
+ function worktreePath(api: TuiPluginApi) {
56
+ return api.state.path.worktree || directoryPath(api);
57
+ }
58
+
59
+ function panelConfig(config: QuotaSidebarConfig): QuotaSidebarConfig {
60
+ return {
61
+ ...config,
62
+ sidebar: {
63
+ ...config.sidebar,
64
+ width: Math.max(8, config.sidebar.width - SECTION_INDENT),
65
+ },
66
+ };
67
+ }
68
+
69
+ function resolveCompactTitle(sessionID: string, persistedTitle?: string) {
70
+ const liveTitle = latestCompactTitles.get(sessionID);
71
+ if (liveTitle && looksDecorated(liveTitle)) return liveTitle;
72
+ if (persistedTitle && looksDecorated(persistedTitle)) return persistedTitle;
73
+ return liveTitle || persistedTitle;
74
+ }
75
+
76
+ async function loadSidebarPanel(
77
+ api: TuiPluginApi,
78
+ sessionID: string,
79
+ ): Promise<SidebarPanelData> {
80
+ const statePath = stateFilePath(resolveOpencodeDataDir());
81
+ const config = await loadConfig(
82
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
83
+ );
84
+ // Session payload lives in day chunks that the server updates from a
85
+ // separate process, so TUI should re-read persisted state instead of keeping
86
+ // an extra full-state cache here.
87
+ const state = await loadState(statePath);
88
+ const session = state.sessions[sessionID];
89
+ const enabled = config.sidebar.enabled;
90
+ const width = Math.max(8, config.sidebar.width - SECTION_INDENT);
91
+ const liveEntries = api.state.session.messages(sessionID).map((info) => ({
92
+ info,
93
+ })) as Parameters<typeof summarizeMessages>[0];
94
+
95
+ const liveUsage = summarizeMessages(liveEntries, 0, 1);
96
+ const cachedUsage = session?.sidebarPanel?.usage || session?.usage;
97
+ const persistedUsage = cachedUsage
98
+ ? fromCachedSessionUsage(cachedUsage)
99
+ : undefined;
100
+ const usage = mergeLiveAndPersistedPanelUsage(
101
+ liveUsage.assistantMessages > 0 ? liveUsage : undefined,
102
+ persistedUsage,
103
+ );
104
+ const compactTitle = resolveCompactTitle(
105
+ sessionID,
106
+ session?.lastAppliedTitle,
107
+ );
108
+
109
+ if (!enabled) {
110
+ return {
111
+ enabled,
112
+ width,
113
+ usageLines: [],
114
+ quotaGroups: [],
115
+ compactTitle: session?.lastAppliedTitle,
116
+ };
117
+ }
118
+
119
+ const usageLines = usage
120
+ ? renderSidebarUsageLines(usage, panelConfig(config))
121
+ : [];
122
+ const quotaGroups = renderSidebarQuotaGroups(
123
+ sidebarPanelQuotaSnapshots(session?.sidebarPanel),
124
+ panelConfig(config),
125
+ );
126
+
127
+ return {
128
+ enabled,
129
+ width,
130
+ usageLines,
131
+ quotaGroups,
132
+ compactTitle,
133
+ };
134
+ }
135
+
136
+ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
137
+ const [panel, setPanel] = createSignal<SidebarPanelData | undefined>();
138
+ let disposed = false;
139
+ let loadVersion = 0;
140
+
141
+ const reload = () => {
142
+ const currentVersion = ++loadVersion;
143
+ const currentSessionID = sessionID();
144
+ void loadSidebarPanel(api, currentSessionID)
145
+ .then((next) => {
146
+ if (disposed || currentVersion !== loadVersion) return;
147
+ setPanel(next);
148
+ })
149
+ .catch((error) => {
150
+ if (disposed || currentVersion !== loadVersion) return;
151
+ void error;
152
+ });
153
+ };
154
+
155
+ reload();
156
+
157
+ const timers = new Set<ReturnType<typeof setTimeout>>();
158
+ const queueRefresh = (delay = 250) => {
159
+ const timer = setTimeout(() => {
160
+ timers.delete(timer);
161
+ reload();
162
+ }, delay);
163
+ timers.add(timer);
164
+ };
165
+
166
+ const scheduleRefresh = () => {
167
+ queueRefresh(150);
168
+ queueRefresh(600);
169
+ };
170
+
171
+ // Bulk session sync populates messages asynchronously without emitting the
172
+ // real-time message.updated events we listen to below. Retry a few times on
173
+ // mount so historical sessions can render usage once the sync finishes.
174
+ queueRefresh(500);
175
+ queueRefresh(1_500);
176
+ queueRefresh(4_000);
177
+
178
+ const unsubscribers = [
179
+ api.event.on("session.updated", (event) => {
180
+ if (event.properties.info.id === sessionID()) {
181
+ scheduleRefresh();
182
+ }
183
+ }),
184
+ api.event.on("message.updated", (event) => {
185
+ if (event.properties.info.sessionID === sessionID()) {
186
+ scheduleRefresh();
187
+ }
188
+ }),
189
+ api.event.on("message.removed", (event) => {
190
+ if (event.properties.sessionID === sessionID()) {
191
+ scheduleRefresh();
192
+ }
193
+ }),
194
+ api.event.on("tui.session.select", (event) => {
195
+ if (event.properties.sessionID === sessionID()) {
196
+ scheduleRefresh();
197
+ }
198
+ }),
199
+ ];
200
+
201
+ onCleanup(() => {
202
+ disposed = true;
203
+ for (const timer of timers) clearTimeout(timer);
204
+ timers.clear();
205
+ for (const unsubscribe of unsubscribers) unsubscribe();
206
+ });
207
+
208
+ return panel;
209
+ }
210
+
211
+ function SectionHeading(props: {
212
+ api: TuiPluginApi;
213
+ value: string;
214
+ collapsible?: boolean;
215
+ open?: boolean;
216
+ summary?: string;
217
+ onToggle?: () => void;
218
+ }) {
219
+ const clickable = () => props.collapsible === true && props.onToggle;
220
+
221
+ return (
222
+ <box
223
+ flexDirection="row"
224
+ gap={1}
225
+ onMouseDown={() => {
226
+ if (!clickable()) return;
227
+ props.onToggle?.();
228
+ }}
229
+ >
230
+ <Show when={props.collapsible}>
231
+ <text fg={props.api.theme.current.text}>{props.open ? "▼" : "▶"}</text>
232
+ </Show>
233
+ <text fg={props.api.theme.current.text}>
234
+ <b>{props.value}</b>
235
+ <Show when={props.summary}>
236
+ <span style={{ fg: props.api.theme.current.textMuted }}>
237
+ {" "}
238
+ {props.summary}
239
+ </span>
240
+ </Show>
241
+ </text>
242
+ </box>
243
+ );
244
+ }
245
+
246
+ function quotaToneColor(api: TuiPluginApi, tone: SidebarQuotaGroup["tone"]) {
247
+ const theme = api.theme.current;
248
+ if (tone === "success") return theme.success;
249
+ if (tone === "warning") return theme.warning;
250
+ if (tone === "error") return theme.error;
251
+ return theme.textMuted;
252
+ }
253
+
254
+ function QuotaGroupBlock(props: {
255
+ api: TuiPluginApi;
256
+ group: SidebarQuotaGroup;
257
+ bullet: boolean;
258
+ }) {
259
+ const content = (
260
+ <box gap={0}>
261
+ <text>
262
+ <span style={{ fg: props.api.theme.current.text }}>
263
+ {props.group.shortLabel}
264
+ </span>
265
+ <Show when={props.group.detail}>
266
+ <span style={{ fg: props.api.theme.current.textMuted }}>
267
+ {" "}
268
+ {props.group.detail}
269
+ </span>
270
+ </Show>
271
+ </text>
272
+ <For each={props.group.continuationLines}>
273
+ {(line) => <text fg={props.api.theme.current.textMuted}>{line}</text>}
274
+ </For>
275
+ </box>
276
+ );
277
+
278
+ return (
279
+ <Show when={props.bullet} fallback={content}>
280
+ <box flexDirection="row" gap={1}>
281
+ <text flexShrink={0} fg={quotaToneColor(props.api, props.group.tone)}>
282
+
283
+ </text>
284
+ {content}
285
+ </box>
286
+ </Show>
287
+ );
288
+ }
289
+
290
+ function fallbackUsageCostLineFromTitle(title: string, width: number) {
291
+ // Legacy compatibility: historical sessions may only have compact-title cost
292
+ // tokens and no persisted sidebar panel payload yet.
293
+ const apiCost = (title || "")
294
+ .split(" | ")
295
+ .map((part) => part.trim())
296
+ .find(
297
+ (part) =>
298
+ /^API\$/.test(part) ||
299
+ /^API\s+\$/.test(part) ||
300
+ /^Est\$/.test(part) ||
301
+ /^Est\s+\$/.test(part),
302
+ );
303
+ if (!apiCost) return undefined;
304
+ return fitLine(
305
+ apiCost.replace(/^Est\$/, "API $").replace(/^API\$/, "API $"),
306
+ width,
307
+ );
308
+ }
309
+
310
+ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
311
+ const panel = useSidebarPanelData(props.api, () => props.sessionID);
312
+ const [quotaOpen, setQuotaOpen] = createSignal(true);
313
+ const width = createMemo(
314
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
315
+ );
316
+ const compactTitle = createMemo(() => {
317
+ compactTitleVersion();
318
+ return resolveCompactTitle(props.sessionID, panel()?.compactTitle) || "";
319
+ });
320
+ const usageLines = createMemo(() => {
321
+ const liveLines = panel()?.usageLines || [];
322
+ const hasCostLine = liveLines.some((line) => /^(?:API|Est)\b/.test(line));
323
+ if (hasCostLine) return liveLines;
324
+ const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width());
325
+ return costLine ? [...liveLines, costLine] : liveLines;
326
+ });
327
+ const quotaGroups = createMemo(() => {
328
+ const liveGroups = panel()?.quotaGroups || [];
329
+ if (liveGroups.length > 0) return liveGroups;
330
+ return fallbackQuotaGroupsFromTitle(compactTitle(), width());
331
+ });
332
+ const hasUsage = createMemo(() => usageLines().length > 0);
333
+ const hasQuota = createMemo(() => quotaGroups().length > 0);
334
+ const quotaBullets = createMemo(() => quotaGroupsUseBullets(quotaGroups()));
335
+ const quotaCollapsible = createMemo(() =>
336
+ quotaGroupsAreCollapsible(quotaGroups()),
337
+ );
338
+ const quotaSummary = createMemo(() => {
339
+ if (!quotaCollapsible() || quotaOpen()) return undefined;
340
+ return quotaGroupsSummary(quotaGroups());
341
+ });
342
+
343
+ return (
344
+ <box gap={0}>
345
+ <Show when={hasUsage()}>
346
+ <box gap={0}>
347
+ <SectionHeading api={props.api} value="Usage" />
348
+ <box gap={0}>
349
+ <For each={usageLines()}>
350
+ {(line) => (
351
+ <text fg={props.api.theme.current.textMuted}>{line}</text>
352
+ )}
353
+ </For>
354
+ </box>
355
+ </box>
356
+ </Show>
357
+
358
+ <Show when={hasQuota()}>
359
+ <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
360
+ <SectionHeading
361
+ api={props.api}
362
+ value="Quota"
363
+ collapsible={quotaCollapsible()}
364
+ open={quotaOpen()}
365
+ summary={quotaSummary()}
366
+ onToggle={() => setQuotaOpen((value) => !value)}
367
+ />
368
+ <Show when={!quotaCollapsible() || quotaOpen()}>
369
+ <box gap={0}>
370
+ <For each={quotaGroups()}>
371
+ {(group) => (
372
+ <QuotaGroupBlock
373
+ api={props.api}
374
+ group={group}
375
+ bullet={quotaBullets()}
376
+ />
377
+ )}
378
+ </For>
379
+ </box>
380
+ </Show>
381
+ </box>
382
+ </Show>
383
+ </box>
384
+ );
385
+ }
386
+
387
+ function SidebarTitleView(props: {
388
+ api: TuiPluginApi;
389
+ sessionID: string;
390
+ title: string;
391
+ shareURL?: string;
392
+ }) {
393
+ const panel = useSidebarPanelData(props.api, () => props.sessionID);
394
+ const width = createMemo(
395
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
396
+ );
397
+ const titleLines = createMemo(() => {
398
+ const baseTitle = normalizeBaseTitle(props.title || "Session") || "Session";
399
+ return baseTitle
400
+ .split(/\r?\n/)
401
+ .filter(Boolean)
402
+ .map((line) => fitLine(line, width()));
403
+ });
404
+ const shareLine = createMemo(() =>
405
+ props.shareURL ? fitLine(props.shareURL, width()) : undefined,
406
+ );
407
+
408
+ return (
409
+ <box gap={0} paddingRight={1}>
410
+ <box gap={0}>
411
+ <For each={titleLines()}>
412
+ {(line) => (
413
+ <text fg={props.api.theme.current.text}>
414
+ <b>{line}</b>
415
+ </text>
416
+ )}
417
+ </For>
418
+ <Show when={shareLine()}>
419
+ <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
420
+ </Show>
421
+ </box>
422
+ </box>
423
+ );
424
+ }
425
+
426
+ const tui: TuiPlugin = async (api) => {
427
+ const config = await loadConfig(
428
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
429
+ );
430
+ let didDeactivateContext = false;
431
+ if (config.sidebar.enabled) {
432
+ const contextPlugin = api.plugins
433
+ .list()
434
+ .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID);
435
+ if (contextPlugin?.active) {
436
+ didDeactivateContext = await api.plugins
437
+ .deactivate(INTERNAL_CONTEXT_PLUGIN_ID)
438
+ .catch(() => false);
439
+ }
440
+ }
441
+ api.lifecycle.onDispose(() => {
442
+ if (!didDeactivateContext) return;
443
+ return api.plugins
444
+ .activate(INTERNAL_CONTEXT_PLUGIN_ID)
445
+ .then(() => undefined)
446
+ .catch(() => undefined);
447
+ });
448
+
449
+ api.slots.register({
450
+ order: 100,
451
+ slots: {
452
+ sidebar_title(
453
+ _ctx: unknown,
454
+ props: { session_id: string; title: string; share_url?: string },
455
+ ) {
456
+ if (latestCompactTitles.get(props.session_id) !== props.title) {
457
+ latestCompactTitles.set(props.session_id, props.title);
458
+ setCompactTitleVersion((value) => value + 1);
459
+ }
460
+ return (
461
+ <SidebarTitleView
462
+ api={api}
463
+ sessionID={props.session_id}
464
+ title={props.title}
465
+ shareURL={props.share_url}
466
+ />
467
+ );
468
+ },
469
+ sidebar_content(_ctx: unknown, props: { session_id: string }) {
470
+ return <SidebarContentView api={api} sessionID={props.session_id} />;
471
+ },
472
+ },
473
+ });
474
+ };
475
+
476
+ const plugin: TuiPluginModule & { id: string } = {
477
+ id,
478
+ tui,
479
+ };
480
+
481
+ export default plugin;