@leo000001/opencode-quota-sidebar 4.0.16 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.tsx CHANGED
@@ -1,481 +1,478 @@
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;
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(sessionID, session?.lastAppliedTitle)
105
+
106
+ if (!enabled) {
107
+ return {
108
+ enabled,
109
+ width,
110
+ usageLines: [],
111
+ quotaGroups: [],
112
+ compactTitle: session?.lastAppliedTitle,
113
+ }
114
+ }
115
+
116
+ const usageLines = usage
117
+ ? renderSidebarUsageLines(usage, panelConfig(config))
118
+ : []
119
+ const quotaGroups = renderSidebarQuotaGroups(
120
+ sidebarPanelQuotaSnapshots(session?.sidebarPanel),
121
+ panelConfig(config),
122
+ )
123
+
124
+ return {
125
+ enabled,
126
+ width,
127
+ usageLines,
128
+ quotaGroups,
129
+ compactTitle,
130
+ }
131
+ }
132
+
133
+ function useSidebarPanelData(api: TuiPluginApi, sessionID: () => string) {
134
+ const [panel, setPanel] = createSignal<SidebarPanelData | undefined>()
135
+ let disposed = false
136
+ let loadVersion = 0
137
+
138
+ const reload = () => {
139
+ const currentVersion = ++loadVersion
140
+ const currentSessionID = sessionID()
141
+ void loadSidebarPanel(api, currentSessionID)
142
+ .then((next) => {
143
+ if (disposed || currentVersion !== loadVersion) return
144
+ setPanel(next)
145
+ })
146
+ .catch((error) => {
147
+ if (disposed || currentVersion !== loadVersion) return
148
+ void error
149
+ })
150
+ }
151
+
152
+ reload()
153
+
154
+ const timers = new Set<ReturnType<typeof setTimeout>>()
155
+ const queueRefresh = (delay = 250) => {
156
+ const timer = setTimeout(() => {
157
+ timers.delete(timer)
158
+ reload()
159
+ }, delay)
160
+ timers.add(timer)
161
+ }
162
+
163
+ const scheduleRefresh = () => {
164
+ queueRefresh(150)
165
+ queueRefresh(600)
166
+ }
167
+
168
+ // Bulk session sync populates messages asynchronously without emitting the
169
+ // real-time message.updated events we listen to below. Retry a few times on
170
+ // mount so historical sessions can render usage once the sync finishes.
171
+ queueRefresh(500)
172
+ queueRefresh(1_500)
173
+ queueRefresh(4_000)
174
+
175
+ const unsubscribers = [
176
+ api.event.on('session.updated', (event) => {
177
+ if (event.properties.info.id === sessionID()) {
178
+ scheduleRefresh()
179
+ }
180
+ }),
181
+ api.event.on('message.updated', (event) => {
182
+ if (event.properties.info.sessionID === sessionID()) {
183
+ scheduleRefresh()
184
+ }
185
+ }),
186
+ api.event.on('message.removed', (event) => {
187
+ if (event.properties.sessionID === sessionID()) {
188
+ scheduleRefresh()
189
+ }
190
+ }),
191
+ api.event.on('tui.session.select', (event) => {
192
+ if (event.properties.sessionID === sessionID()) {
193
+ scheduleRefresh()
194
+ }
195
+ }),
196
+ ]
197
+
198
+ onCleanup(() => {
199
+ disposed = true
200
+ for (const timer of timers) clearTimeout(timer)
201
+ timers.clear()
202
+ for (const unsubscribe of unsubscribers) unsubscribe()
203
+ })
204
+
205
+ return panel
206
+ }
207
+
208
+ function SectionHeading(props: {
209
+ api: TuiPluginApi
210
+ value: string
211
+ collapsible?: boolean
212
+ open?: boolean
213
+ summary?: string
214
+ onToggle?: () => void
215
+ }) {
216
+ const clickable = () => props.collapsible === true && props.onToggle
217
+
218
+ return (
219
+ <box
220
+ flexDirection="row"
221
+ gap={1}
222
+ onMouseDown={() => {
223
+ if (!clickable()) return
224
+ props.onToggle?.()
225
+ }}
226
+ >
227
+ <Show when={props.collapsible}>
228
+ <text fg={props.api.theme.current.text}>{props.open ? '▼' : '▶'}</text>
229
+ </Show>
230
+ <text fg={props.api.theme.current.text}>
231
+ <b>{props.value}</b>
232
+ <Show when={props.summary}>
233
+ <span style={{ fg: props.api.theme.current.textMuted }}>
234
+ {' '}
235
+ {props.summary}
236
+ </span>
237
+ </Show>
238
+ </text>
239
+ </box>
240
+ )
241
+ }
242
+
243
+ function quotaToneColor(api: TuiPluginApi, tone: SidebarQuotaGroup['tone']) {
244
+ const theme = api.theme.current
245
+ if (tone === 'success') return theme.success
246
+ if (tone === 'warning') return theme.warning
247
+ if (tone === 'error') return theme.error
248
+ return theme.textMuted
249
+ }
250
+
251
+ function QuotaGroupBlock(props: {
252
+ api: TuiPluginApi
253
+ group: SidebarQuotaGroup
254
+ bullet: boolean
255
+ }) {
256
+ const content = (
257
+ <box gap={0}>
258
+ <text>
259
+ <span style={{ fg: props.api.theme.current.text }}>
260
+ {props.group.shortLabel}
261
+ </span>
262
+ <Show when={props.group.detail}>
263
+ <span style={{ fg: props.api.theme.current.textMuted }}>
264
+ {' '}
265
+ {props.group.detail}
266
+ </span>
267
+ </Show>
268
+ </text>
269
+ <For each={props.group.continuationLines}>
270
+ {(line) => <text fg={props.api.theme.current.textMuted}>{line}</text>}
271
+ </For>
272
+ </box>
273
+ )
274
+
275
+ return (
276
+ <Show when={props.bullet} fallback={content}>
277
+ <box flexDirection="row" gap={1}>
278
+ <text flexShrink={0} fg={quotaToneColor(props.api, props.group.tone)}>
279
+
280
+ </text>
281
+ {content}
282
+ </box>
283
+ </Show>
284
+ )
285
+ }
286
+
287
+ function fallbackUsageCostLineFromTitle(title: string, width: number) {
288
+ // Legacy compatibility: historical sessions may only have compact-title cost
289
+ // tokens and no persisted sidebar panel payload yet.
290
+ const apiCost = (title || '')
291
+ .split(' | ')
292
+ .map((part) => part.trim())
293
+ .find(
294
+ (part) =>
295
+ /^API\$/.test(part) ||
296
+ /^API\s+\$/.test(part) ||
297
+ /^Est\$/.test(part) ||
298
+ /^Est\s+\$/.test(part),
299
+ )
300
+ if (!apiCost) return undefined
301
+ return fitLine(
302
+ apiCost.replace(/^Est\$/, 'API $').replace(/^API\$/, 'API $'),
303
+ width,
304
+ )
305
+ }
306
+
307
+ function SidebarContentView(props: { api: TuiPluginApi; sessionID: string }) {
308
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
309
+ const [quotaOpen, setQuotaOpen] = createSignal(true)
310
+ const width = createMemo(
311
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
312
+ )
313
+ const compactTitle = createMemo(() => {
314
+ compactTitleVersion()
315
+ return resolveCompactTitle(props.sessionID, panel()?.compactTitle) || ''
316
+ })
317
+ const usageLines = createMemo(() => {
318
+ const liveLines = panel()?.usageLines || []
319
+ const hasCostLine = liveLines.some((line) => /^(?:API|Est)\b/.test(line))
320
+ if (hasCostLine) return liveLines
321
+ const costLine = fallbackUsageCostLineFromTitle(compactTitle(), width())
322
+ return costLine ? [...liveLines, costLine] : liveLines
323
+ })
324
+ const quotaGroups = createMemo(() => {
325
+ const liveGroups = panel()?.quotaGroups || []
326
+ if (liveGroups.length > 0) return liveGroups
327
+ return fallbackQuotaGroupsFromTitle(compactTitle(), width())
328
+ })
329
+ const hasUsage = createMemo(() => usageLines().length > 0)
330
+ const hasQuota = createMemo(() => quotaGroups().length > 0)
331
+ const quotaBullets = createMemo(() => quotaGroupsUseBullets(quotaGroups()))
332
+ const quotaCollapsible = createMemo(() =>
333
+ quotaGroupsAreCollapsible(quotaGroups()),
334
+ )
335
+ const quotaSummary = createMemo(() => {
336
+ if (!quotaCollapsible() || quotaOpen()) return undefined
337
+ return quotaGroupsSummary(quotaGroups())
338
+ })
339
+
340
+ return (
341
+ <box gap={0}>
342
+ <Show when={hasUsage()}>
343
+ <box gap={0}>
344
+ <SectionHeading api={props.api} value="Usage" />
345
+ <box gap={0}>
346
+ <For each={usageLines()}>
347
+ {(line) => (
348
+ <text fg={props.api.theme.current.textMuted}>{line}</text>
349
+ )}
350
+ </For>
351
+ </box>
352
+ </box>
353
+ </Show>
354
+
355
+ <Show when={hasQuota()}>
356
+ <box paddingTop={hasUsage() ? 1 : 0} gap={0}>
357
+ <SectionHeading
358
+ api={props.api}
359
+ value="Quota"
360
+ collapsible={quotaCollapsible()}
361
+ open={quotaOpen()}
362
+ summary={quotaSummary()}
363
+ onToggle={() => setQuotaOpen((value) => !value)}
364
+ />
365
+ <Show when={!quotaCollapsible() || quotaOpen()}>
366
+ <box gap={0}>
367
+ <For each={quotaGroups()}>
368
+ {(group) => (
369
+ <QuotaGroupBlock
370
+ api={props.api}
371
+ group={group}
372
+ bullet={quotaBullets()}
373
+ />
374
+ )}
375
+ </For>
376
+ </box>
377
+ </Show>
378
+ </box>
379
+ </Show>
380
+ </box>
381
+ )
382
+ }
383
+
384
+ function SidebarTitleView(props: {
385
+ api: TuiPluginApi
386
+ sessionID: string
387
+ title: string
388
+ shareURL?: string
389
+ }) {
390
+ const panel = useSidebarPanelData(props.api, () => props.sessionID)
391
+ const width = createMemo(
392
+ () => panel()?.width || DEFAULT_WIDTH - SECTION_INDENT,
393
+ )
394
+ const titleLines = createMemo(() => {
395
+ const baseTitle = normalizeBaseTitle(props.title || 'Session') || 'Session'
396
+ return baseTitle
397
+ .split(/\r?\n/)
398
+ .filter(Boolean)
399
+ .map((line) => fitLine(line, width()))
400
+ })
401
+ const shareLine = createMemo(() =>
402
+ props.shareURL ? fitLine(props.shareURL, width()) : undefined,
403
+ )
404
+
405
+ return (
406
+ <box gap={0} paddingRight={1}>
407
+ <box gap={0}>
408
+ <For each={titleLines()}>
409
+ {(line) => (
410
+ <text fg={props.api.theme.current.text}>
411
+ <b>{line}</b>
412
+ </text>
413
+ )}
414
+ </For>
415
+ <Show when={shareLine()}>
416
+ <text fg={props.api.theme.current.textMuted}>{shareLine()}</text>
417
+ </Show>
418
+ </box>
419
+ </box>
420
+ )
421
+ }
422
+
423
+ const tui: TuiPlugin = async (api) => {
424
+ const config = await loadConfig(
425
+ quotaConfigPaths(worktreePath(api), directoryPath(api)),
426
+ )
427
+ let didDeactivateContext = false
428
+ if (config.sidebar.enabled) {
429
+ const contextPlugin = api.plugins
430
+ .list()
431
+ .find((item) => item.id === INTERNAL_CONTEXT_PLUGIN_ID)
432
+ if (contextPlugin?.active) {
433
+ didDeactivateContext = await api.plugins
434
+ .deactivate(INTERNAL_CONTEXT_PLUGIN_ID)
435
+ .catch(() => false)
436
+ }
437
+ }
438
+ api.lifecycle.onDispose(() => {
439
+ if (!didDeactivateContext) return
440
+ return api.plugins
441
+ .activate(INTERNAL_CONTEXT_PLUGIN_ID)
442
+ .then(() => undefined)
443
+ .catch(() => undefined)
444
+ })
445
+
446
+ api.slots.register({
447
+ order: 100,
448
+ slots: {
449
+ sidebar_title(
450
+ _ctx: unknown,
451
+ props: { session_id: string; title: string; share_url?: string },
452
+ ) {
453
+ if (latestCompactTitles.get(props.session_id) !== props.title) {
454
+ latestCompactTitles.set(props.session_id, props.title)
455
+ setCompactTitleVersion((value) => value + 1)
456
+ }
457
+ return (
458
+ <SidebarTitleView
459
+ api={api}
460
+ sessionID={props.session_id}
461
+ title={props.title}
462
+ shareURL={props.share_url}
463
+ />
464
+ )
465
+ },
466
+ sidebar_content(_ctx: unknown, props: { session_id: string }) {
467
+ return <SidebarContentView api={api} sessionID={props.session_id} />
468
+ },
469
+ },
470
+ })
471
+ }
472
+
473
+ const plugin: TuiPluginModule & { id: string } = {
474
+ id,
475
+ tui,
476
+ }
477
+
478
+ export default plugin