@leo000001/opencode-quota-sidebar 2.0.23 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -34
- package/dist/format.d.ts +10 -6
- package/dist/format.js +102 -55
- package/dist/index.js +12 -56
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +12 -0
- package/dist/storage_chunks.js +21 -7
- package/dist/storage_parse.d.ts +2 -2
- package/dist/storage_parse.js +94 -63
- package/dist/title_apply.js +24 -4
- package/dist/tui.d.ts +6 -0
- package/dist/tui.tsx +363 -0
- package/dist/types.d.ts +10 -2
- package/package.json +21 -7
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.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { SessionState } from './types.js';
|
|
1
|
+
import type { QuotaSnapshot, SessionState } from './types.js';
|
|
2
2
|
export declare function parseSessionState(value: unknown): SessionState | undefined;
|
|
3
|
-
export declare function parseQuotaCache(value: unknown): Record<string,
|
|
3
|
+
export declare function parseQuotaCache(value: unknown): Record<string, QuotaSnapshot>;
|
package/dist/storage_parse.js
CHANGED
|
@@ -105,6 +105,96 @@ function parseCachedUsage(value) {
|
|
|
105
105
|
providers,
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
function parseQuotaSnapshot(value) {
|
|
109
|
+
if (!isRecord(value))
|
|
110
|
+
return undefined;
|
|
111
|
+
const checkedAt = asNumber(value.checkedAt, 0);
|
|
112
|
+
if (!checkedAt)
|
|
113
|
+
return undefined;
|
|
114
|
+
const status = value.status;
|
|
115
|
+
if (status !== 'ok' &&
|
|
116
|
+
status !== 'unavailable' &&
|
|
117
|
+
status !== 'unsupported' &&
|
|
118
|
+
status !== 'error') {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const label = typeof value.label === 'string' ? value.label : '';
|
|
122
|
+
const adapterID = typeof value.adapterID === 'string' ? value.adapterID : undefined;
|
|
123
|
+
const shortLabel = typeof value.shortLabel === 'string' ? value.shortLabel : undefined;
|
|
124
|
+
const sortOrder = typeof value.sortOrder === 'number' ? value.sortOrder : undefined;
|
|
125
|
+
const balance = isRecord(value.balance)
|
|
126
|
+
? {
|
|
127
|
+
amount: typeof value.balance.amount === 'number' ? value.balance.amount : 0,
|
|
128
|
+
currency: typeof value.balance.currency === 'string'
|
|
129
|
+
? value.balance.currency
|
|
130
|
+
: '$',
|
|
131
|
+
}
|
|
132
|
+
: undefined;
|
|
133
|
+
const windows = Array.isArray(value.windows)
|
|
134
|
+
? value.windows
|
|
135
|
+
.filter((window) => isRecord(window))
|
|
136
|
+
.map((window) => ({
|
|
137
|
+
label: typeof window.label === 'string' ? window.label : '',
|
|
138
|
+
showPercent: typeof window.showPercent === 'boolean'
|
|
139
|
+
? window.showPercent
|
|
140
|
+
: undefined,
|
|
141
|
+
resetLabel: typeof window.resetLabel === 'string'
|
|
142
|
+
? window.resetLabel
|
|
143
|
+
: undefined,
|
|
144
|
+
note: typeof window.note === 'string' ? window.note : undefined,
|
|
145
|
+
remainingPercent: typeof window.remainingPercent === 'number'
|
|
146
|
+
? window.remainingPercent
|
|
147
|
+
: undefined,
|
|
148
|
+
usedPercent: typeof window.usedPercent === 'number'
|
|
149
|
+
? window.usedPercent
|
|
150
|
+
: undefined,
|
|
151
|
+
resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
|
|
152
|
+
}))
|
|
153
|
+
.filter((window) => window.label || window.remainingPercent !== undefined)
|
|
154
|
+
: undefined;
|
|
155
|
+
return {
|
|
156
|
+
providerID: typeof value.providerID === 'string' ? value.providerID : label,
|
|
157
|
+
adapterID,
|
|
158
|
+
label,
|
|
159
|
+
shortLabel,
|
|
160
|
+
sortOrder,
|
|
161
|
+
status,
|
|
162
|
+
checkedAt,
|
|
163
|
+
remainingPercent: typeof value.remainingPercent === 'number'
|
|
164
|
+
? value.remainingPercent
|
|
165
|
+
: undefined,
|
|
166
|
+
usedPercent: typeof value.usedPercent === 'number' ? value.usedPercent : undefined,
|
|
167
|
+
resetAt: typeof value.resetAt === 'string' ? value.resetAt : undefined,
|
|
168
|
+
expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
|
|
169
|
+
balance,
|
|
170
|
+
note: typeof value.note === 'string' ? value.note : undefined,
|
|
171
|
+
windows,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function parseQuotaSnapshots(value) {
|
|
175
|
+
if (!Array.isArray(value))
|
|
176
|
+
return undefined;
|
|
177
|
+
const parsed = value
|
|
178
|
+
.map((item) => parseQuotaSnapshot(item))
|
|
179
|
+
.filter((item) => Boolean(item));
|
|
180
|
+
return parsed.length > 0 ? parsed : [];
|
|
181
|
+
}
|
|
182
|
+
function parseSidebarPanel(value) {
|
|
183
|
+
if (!isRecord(value))
|
|
184
|
+
return undefined;
|
|
185
|
+
const version = asNumber(value.version, 1);
|
|
186
|
+
if (version !== 1)
|
|
187
|
+
return undefined;
|
|
188
|
+
const updatedAt = asNumber(value.updatedAt, 0);
|
|
189
|
+
if (!updatedAt)
|
|
190
|
+
return undefined;
|
|
191
|
+
return {
|
|
192
|
+
version: 1,
|
|
193
|
+
updatedAt,
|
|
194
|
+
usage: parseCachedUsage(value.usage),
|
|
195
|
+
quotas: parseQuotaSnapshots(value.quotas),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
108
198
|
function parseCursor(value) {
|
|
109
199
|
if (!isRecord(value))
|
|
110
200
|
return undefined;
|
|
@@ -137,75 +227,16 @@ export function parseSessionState(value) {
|
|
|
137
227
|
usage: parseCachedUsage(value.usage),
|
|
138
228
|
dirty: value.dirty === true,
|
|
139
229
|
cursor: parseCursor(value.cursor),
|
|
230
|
+
sidebarPanel: parseSidebarPanel(value.sidebarPanel),
|
|
140
231
|
};
|
|
141
232
|
}
|
|
142
233
|
export function parseQuotaCache(value) {
|
|
143
234
|
const raw = isRecord(value) ? value : {};
|
|
144
235
|
return Object.entries(raw).reduce((acc, [key, item]) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const checkedAt = asNumber(item.checkedAt, 0);
|
|
148
|
-
if (!checkedAt)
|
|
149
|
-
return acc;
|
|
150
|
-
const status = item.status;
|
|
151
|
-
if (status !== 'ok' &&
|
|
152
|
-
status !== 'unavailable' &&
|
|
153
|
-
status !== 'unsupported' &&
|
|
154
|
-
status !== 'error') {
|
|
236
|
+
const parsed = parseQuotaSnapshot(item);
|
|
237
|
+
if (!parsed)
|
|
155
238
|
return acc;
|
|
156
|
-
}
|
|
157
|
-
const label = typeof item.label === 'string' ? item.label : key;
|
|
158
|
-
const adapterID = typeof item.adapterID === 'string' ? item.adapterID : undefined;
|
|
159
|
-
const shortLabel = typeof item.shortLabel === 'string' ? item.shortLabel : undefined;
|
|
160
|
-
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : undefined;
|
|
161
|
-
const balance = isRecord(item.balance)
|
|
162
|
-
? {
|
|
163
|
-
amount: typeof item.balance.amount === 'number' ? item.balance.amount : 0,
|
|
164
|
-
currency: typeof item.balance.currency === 'string'
|
|
165
|
-
? item.balance.currency
|
|
166
|
-
: '$',
|
|
167
|
-
}
|
|
168
|
-
: undefined;
|
|
169
|
-
const windows = Array.isArray(item.windows)
|
|
170
|
-
? item.windows
|
|
171
|
-
.filter((window) => isRecord(window))
|
|
172
|
-
.map((window) => ({
|
|
173
|
-
label: typeof window.label === 'string' ? window.label : '',
|
|
174
|
-
showPercent: typeof window.showPercent === 'boolean'
|
|
175
|
-
? window.showPercent
|
|
176
|
-
: undefined,
|
|
177
|
-
resetLabel: typeof window.resetLabel === 'string'
|
|
178
|
-
? window.resetLabel
|
|
179
|
-
: undefined,
|
|
180
|
-
note: typeof window.note === 'string' ? window.note : undefined,
|
|
181
|
-
remainingPercent: typeof window.remainingPercent === 'number'
|
|
182
|
-
? window.remainingPercent
|
|
183
|
-
: undefined,
|
|
184
|
-
usedPercent: typeof window.usedPercent === 'number'
|
|
185
|
-
? window.usedPercent
|
|
186
|
-
: undefined,
|
|
187
|
-
resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
|
|
188
|
-
}))
|
|
189
|
-
.filter((window) => window.label || window.remainingPercent !== undefined)
|
|
190
|
-
: undefined;
|
|
191
|
-
acc[key] = {
|
|
192
|
-
providerID: typeof item.providerID === 'string' ? item.providerID : key,
|
|
193
|
-
adapterID,
|
|
194
|
-
label,
|
|
195
|
-
shortLabel,
|
|
196
|
-
sortOrder,
|
|
197
|
-
status,
|
|
198
|
-
checkedAt,
|
|
199
|
-
remainingPercent: typeof item.remainingPercent === 'number'
|
|
200
|
-
? item.remainingPercent
|
|
201
|
-
: undefined,
|
|
202
|
-
usedPercent: typeof item.usedPercent === 'number' ? item.usedPercent : undefined,
|
|
203
|
-
resetAt: typeof item.resetAt === 'string' ? item.resetAt : undefined,
|
|
204
|
-
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
|
205
|
-
balance,
|
|
206
|
-
note: typeof item.note === 'string' ? item.note : undefined,
|
|
207
|
-
windows,
|
|
208
|
-
};
|
|
239
|
+
acc[key] = parsed.label ? parsed : { ...parsed, label: key };
|
|
209
240
|
return acc;
|
|
210
241
|
}, {});
|
|
211
242
|
}
|
package/dist/title_apply.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
|
|
2
|
+
import { toCachedSessionUsage } from './usage.js';
|
|
2
3
|
import { swallow, debug, mapConcurrent } from './helpers.js';
|
|
3
4
|
import { resolveTitleView, selectDesktopCompactProviderIDs, } from './format.js';
|
|
5
|
+
import { collapseQuotaSnapshots } from './quota_render.js';
|
|
4
6
|
export function createTitleApplicator(deps) {
|
|
5
7
|
const pendingAppliedTitle = new Map();
|
|
6
8
|
const recentRestore = new Map();
|
|
@@ -8,8 +10,13 @@ export function createTitleApplicator(deps) {
|
|
|
8
10
|
pendingAppliedTitle.delete(sessionID);
|
|
9
11
|
recentRestore.delete(sessionID);
|
|
10
12
|
};
|
|
13
|
+
const cloneQuotas = (quotas) => quotas.map((quota) => ({
|
|
14
|
+
...quota,
|
|
15
|
+
balance: quota.balance ? { ...quota.balance } : undefined,
|
|
16
|
+
windows: quota.windows?.map((win) => ({ ...win })),
|
|
17
|
+
}));
|
|
11
18
|
const applyTitle = async (sessionID) => {
|
|
12
|
-
if (!deps.config.sidebar.enabled
|
|
19
|
+
if (!deps.config.sidebar.enabled)
|
|
13
20
|
return false;
|
|
14
21
|
let stateMutated = false;
|
|
15
22
|
const session = await deps.client.session
|
|
@@ -74,16 +81,29 @@ export function createTitleApplicator(deps) {
|
|
|
74
81
|
}
|
|
75
82
|
const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
|
|
76
83
|
const view = deps.getTitleView?.(sessionID) ??
|
|
77
|
-
resolveTitleView({ config: deps.config
|
|
84
|
+
resolveTitleView({ config: deps.config });
|
|
78
85
|
const quotaProviders = Array.from(new Set(view === 'compact'
|
|
79
86
|
? selectDesktopCompactProviderIDs(usage, deps.config)
|
|
80
87
|
: Object.keys(usage.providers)));
|
|
81
88
|
const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
|
|
82
89
|
? await deps.getQuotaSnapshots(quotaProviders)
|
|
83
90
|
: [];
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
sessionState.sidebarPanel = {
|
|
92
|
+
version: 1,
|
|
93
|
+
updatedAt: Date.now(),
|
|
94
|
+
usage: toCachedSessionUsage(usage),
|
|
95
|
+
quotas: cloneQuotas(collapseQuotaSnapshots(quotas)),
|
|
96
|
+
};
|
|
97
|
+
stateMutated = true;
|
|
98
|
+
if (!deps.state.titleEnabled) {
|
|
99
|
+
if (stateMutated) {
|
|
100
|
+
deps.markDirty(deps.state.sessionDateMap[sessionID]);
|
|
101
|
+
}
|
|
102
|
+
deps.scheduleSave();
|
|
103
|
+
deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
|
|
86
104
|
return false;
|
|
105
|
+
}
|
|
106
|
+
const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config, view);
|
|
87
107
|
if (canonicalizeTitleForCompare(nextTitle) ===
|
|
88
108
|
canonicalizeTitleForCompare(session.data.title)) {
|
|
89
109
|
if (looksDecorated(session.data.title)) {
|
package/dist/tui.d.ts
ADDED
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
|