@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 +49 -14
- package/dist/format.d.ts +3 -7
- package/dist/format.js +3 -8
- package/dist/index.js +3 -41
- package/dist/storage_chunks.js +21 -7
- package/dist/storage_parse.js +4 -0
- package/dist/title_apply.js +13 -5
- package/dist/tui.d.ts +1 -0
- package/dist/tui.tsx +363 -0
- package/dist/types.d.ts +1 -0
- package/package.json +5 -4
- package/dist/tui.js +0 -181
package/README.md
CHANGED
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@leo000001/opencode-quota-sidebar)
|
|
4
4
|
[](https://github.com/xihuai18/opencode-quota-sidebar/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
OpenCode plugin: show token usage and subscription quota in
|
|
6
|
+
OpenCode plugin: show token usage and subscription quota in TUI sidebar panels and compact shared session titles.
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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`, `
|
|
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 `
|
|
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
|
|
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,
|
|
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: (
|
|
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
|
-
|
|
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) => {
|
package/dist/storage_chunks.js
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/storage_parse.js
CHANGED
|
@@ -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),
|
package/dist/title_apply.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leo000001/opencode-quota-sidebar",
|
|
3
|
-
"version": "
|
|
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.
|
|
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;
|