@leo000001/opencode-quota-sidebar 3.0.10 → 4.0.2

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +0 -1
  2. package/README.md +163 -42
  3. package/README.zh-CN.md +163 -42
  4. package/SECURITY.md +1 -1
  5. package/dist/cli.d.ts +18 -0
  6. package/dist/cli.js +354 -0
  7. package/dist/cli_render.d.ts +17 -0
  8. package/dist/cli_render.js +292 -0
  9. package/dist/events.d.ts +1 -1
  10. package/dist/events.js +2 -2
  11. package/dist/format.d.ts +4 -0
  12. package/dist/format.js +391 -49
  13. package/dist/history_messages.d.ts +8 -0
  14. package/dist/history_messages.js +157 -0
  15. package/dist/history_usage.d.ts +93 -0
  16. package/dist/history_usage.js +251 -0
  17. package/dist/index.js +29 -4
  18. package/dist/period.d.ts +29 -1
  19. package/dist/period.js +187 -9
  20. package/dist/provider_catalog.d.ts +8 -0
  21. package/dist/provider_catalog.js +68 -0
  22. package/dist/providers/core/anthropic.d.ts +1 -1
  23. package/dist/providers/core/anthropic.js +69 -45
  24. package/dist/providers/core/openai.js +38 -2
  25. package/dist/providers/index.d.ts +1 -2
  26. package/dist/providers/index.js +1 -3
  27. package/dist/quota.d.ts +4 -2
  28. package/dist/quota.js +18 -21
  29. package/dist/quota_render.d.ts +1 -1
  30. package/dist/quota_render.js +23 -24
  31. package/dist/quota_service.d.ts +1 -0
  32. package/dist/quota_service.js +151 -19
  33. package/dist/storage.d.ts +1 -1
  34. package/dist/storage.js +4 -4
  35. package/dist/storage_dates.d.ts +1 -1
  36. package/dist/storage_dates.js +8 -5
  37. package/dist/storage_parse.js +23 -1
  38. package/dist/supported_quota.d.ts +4 -0
  39. package/dist/supported_quota.js +36 -0
  40. package/dist/title.js +21 -10
  41. package/dist/tools.d.ts +14 -3
  42. package/dist/tools.js +54 -2
  43. package/dist/tui.tsx +17 -6
  44. package/dist/tui_helpers.js +11 -6
  45. package/dist/types.d.ts +8 -0
  46. package/dist/usage.d.ts +18 -0
  47. package/dist/usage.js +93 -9
  48. package/dist/usage_service.d.ts +4 -1
  49. package/dist/usage_service.js +193 -189
  50. package/package.json +4 -1
  51. package/quota-sidebar.config.example.json +36 -45
  52. package/dist/providers/third_party/xyai.d.ts +0 -2
  53. package/dist/providers/third_party/xyai.js +0 -348
@@ -0,0 +1,157 @@
1
+ import { debug } from './helpers.js';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ function isFiniteNumber(value) {
6
+ return typeof value === 'number' && Number.isFinite(value);
7
+ }
8
+ function decodeTokens(value) {
9
+ if (!isRecord(value))
10
+ return undefined;
11
+ if (!isFiniteNumber(value.input))
12
+ return undefined;
13
+ if (!isFiniteNumber(value.output))
14
+ return undefined;
15
+ const reasoning = isFiniteNumber(value.reasoning) ? value.reasoning : 0;
16
+ const cacheRaw = isRecord(value.cache) ? value.cache : {};
17
+ const read = isFiniteNumber(cacheRaw.read) ? cacheRaw.read : 0;
18
+ const write = isFiniteNumber(cacheRaw.write) ? cacheRaw.write : 0;
19
+ return {
20
+ input: value.input,
21
+ output: value.output,
22
+ reasoning,
23
+ cache: { read, write },
24
+ };
25
+ }
26
+ export function decodeMessageInfo(value) {
27
+ if (!isRecord(value))
28
+ return undefined;
29
+ if (typeof value.id !== 'string')
30
+ return undefined;
31
+ if (typeof value.sessionID !== 'string')
32
+ return undefined;
33
+ if (typeof value.role !== 'string')
34
+ return undefined;
35
+ if (!isRecord(value.time))
36
+ return undefined;
37
+ if (!isFiniteNumber(value.time.created))
38
+ return undefined;
39
+ if (value.time.completed !== undefined &&
40
+ !isFiniteNumber(value.time.completed)) {
41
+ return undefined;
42
+ }
43
+ if (value.role !== 'assistant') {
44
+ return {
45
+ ...value,
46
+ time: {
47
+ created: value.time.created,
48
+ completed: value.time.completed,
49
+ },
50
+ };
51
+ }
52
+ if (typeof value.providerID !== 'string')
53
+ return undefined;
54
+ if (typeof value.modelID !== 'string')
55
+ return undefined;
56
+ const tokens = decodeTokens(value.tokens);
57
+ if (!tokens)
58
+ return undefined;
59
+ return {
60
+ ...value,
61
+ time: {
62
+ created: value.time.created,
63
+ completed: value.time.completed,
64
+ },
65
+ tokens,
66
+ };
67
+ }
68
+ export function decodeMessageEntries(value) {
69
+ if (!Array.isArray(value))
70
+ return undefined;
71
+ const decoded = value
72
+ .map((item) => {
73
+ if (!isRecord(item))
74
+ return undefined;
75
+ const info = decodeMessageInfo(item.info);
76
+ if (!info)
77
+ return undefined;
78
+ return { info };
79
+ })
80
+ .filter((item) => Boolean(item));
81
+ if (decoded.length > 0 && decoded.length < value.length) {
82
+ debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
83
+ return undefined;
84
+ }
85
+ if (decoded.length === 0 && value.length > 0)
86
+ return undefined;
87
+ return decoded;
88
+ }
89
+ export function nextCursorFromResponse(value) {
90
+ if (!isRecord(value))
91
+ return undefined;
92
+ const response = value.response;
93
+ if (!isRecord(response))
94
+ return undefined;
95
+ const headers = response.headers;
96
+ if (!headers || typeof headers.get !== 'function') {
97
+ return undefined;
98
+ }
99
+ const next = headers.get('X-Next-Cursor');
100
+ return typeof next === 'string' && next ? next : undefined;
101
+ }
102
+ function errorStatusCode(value, seen = new Set()) {
103
+ if (!isRecord(value) || seen.has(value))
104
+ return undefined;
105
+ seen.add(value);
106
+ const status = value.status;
107
+ if (typeof status === 'number' && Number.isFinite(status))
108
+ return status;
109
+ const statusCode = value.statusCode;
110
+ if (typeof statusCode === 'number' && Number.isFinite(statusCode)) {
111
+ return statusCode;
112
+ }
113
+ return (errorStatusCode(value.response, seen) ||
114
+ errorStatusCode(value.cause, seen) ||
115
+ errorStatusCode(value.error, seen));
116
+ }
117
+ function errorText(value, seen = new Set()) {
118
+ if (!value || seen.has(value))
119
+ return '';
120
+ if (typeof value === 'string')
121
+ return value;
122
+ if (typeof value === 'number' || typeof value === 'boolean')
123
+ return `${value}`;
124
+ if (value instanceof Error) {
125
+ seen.add(value);
126
+ return [
127
+ value.message,
128
+ errorText(value.cause, seen),
129
+ ]
130
+ .filter(Boolean)
131
+ .join('\n');
132
+ }
133
+ if (!isRecord(value))
134
+ return '';
135
+ seen.add(value);
136
+ return [
137
+ typeof value.message === 'string' ? value.message : '',
138
+ typeof value.error === 'string' ? value.error : '',
139
+ typeof value.detail === 'string' ? value.detail : '',
140
+ typeof value.title === 'string' ? value.title : '',
141
+ errorText(value.response, seen),
142
+ errorText(value.data, seen),
143
+ errorText(value.cause, seen),
144
+ ]
145
+ .filter(Boolean)
146
+ .join('\n');
147
+ }
148
+ export function isMissingSessionError(error) {
149
+ const status = errorStatusCode(error);
150
+ if (status === 404 || status === 410)
151
+ return true;
152
+ const text = errorText(error).toLowerCase();
153
+ if (!text)
154
+ return false;
155
+ return (/\b(session|conversation)\b.*\b(not found|missing|deleted|does not exist)\b/.test(text) ||
156
+ /\b(not found|missing|deleted|does not exist)\b.*\b(session|conversation)\b/.test(text));
157
+ }
@@ -0,0 +1,93 @@
1
+ import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
+ import { type HistoryPeriod, type PeriodRange, type SinceSpec } from './period.js';
3
+ import { type UsageSummary } from './usage.js';
4
+ import type { CacheCoverageMode, CachedSessionUsage, IncrementalCursor } from './types.js';
5
+ export type HistoryDialogRow = {
6
+ label: string;
7
+ isCurrent: boolean;
8
+ usage: UsageSummary;
9
+ };
10
+ export type HistoryDialogData = {
11
+ period: HistoryPeriod;
12
+ since: string;
13
+ rows: HistoryDialogRow[];
14
+ total: UsageSummary;
15
+ warning?: string;
16
+ };
17
+ export type HistoryUsageRow = {
18
+ range: PeriodRange;
19
+ usage: UsageSummary;
20
+ };
21
+ export type HistoryUsageResult = {
22
+ period: HistoryPeriod;
23
+ since: SinceSpec;
24
+ rows: HistoryUsageRow[];
25
+ total: UsageSummary;
26
+ warning?: string;
27
+ persistenceHints?: HistoryPersistenceHint[];
28
+ };
29
+ type MessageEntry = {
30
+ info: Message;
31
+ };
32
+ export type HistoryPersistenceHint = {
33
+ sessionID: string;
34
+ dateKey: string;
35
+ lastMessageTime: number | undefined;
36
+ dirty: boolean;
37
+ ranges: UsageSummary[];
38
+ totalUsage: UsageSummary;
39
+ fullUsage: UsageSummary | undefined;
40
+ persist: boolean;
41
+ cursor: IncrementalCursor | undefined;
42
+ missing: boolean;
43
+ loadFailed: boolean;
44
+ };
45
+ export type LoadMessagesPageResult = {
46
+ status: 'ok';
47
+ entries: MessageEntry[];
48
+ nextBefore?: string;
49
+ } | {
50
+ status: 'missing';
51
+ } | {
52
+ status: 'error';
53
+ };
54
+ type SessionEntry = {
55
+ sessionID: string;
56
+ dateKey: string;
57
+ state: {
58
+ createdAt: number;
59
+ cursor?: IncrementalCursor;
60
+ dirty?: boolean;
61
+ usage?: CachedSessionUsage;
62
+ };
63
+ };
64
+ export type ComputeHistoryUsageDeps = {
65
+ /** All known sessions (from memory state + disk chunks). */
66
+ sessions: SessionEntry[];
67
+ /** Paged message loader — injected so both server and TUI can provide their own implementation. */
68
+ loadMessagesPage: (sessionID: string, before?: string) => Promise<LoadMessagesPageResult>;
69
+ /** Model pricing map for API-cost estimation. */
70
+ getModelCostMap: () => Promise<Record<string, unknown>>;
71
+ /** Calculate equivalent API cost for a single assistant message. */
72
+ calcApiCost: (message: AssistantMessage, modelCostMap: Record<string, unknown>) => number;
73
+ /** Classify cache coverage mode for a single assistant message. */
74
+ classifyCacheMode: (message: AssistantMessage, modelCostMap: Record<string, unknown>) => CacheCoverageMode;
75
+ /** Check whether a set of entries has at least one resolvable API-cost message. */
76
+ hasResolvableApiCostMessages: (entries: MessageEntry[], modelCostMap: Record<string, unknown>) => boolean;
77
+ /** Whether the cached usage for a session needs a full recompute. */
78
+ shouldTrackFullUsage: (cached: CachedSessionUsage | undefined, hasPricing: boolean) => boolean;
79
+ /** Whether cached usage needs recompute (for persistence decision). */
80
+ shouldRecomputeUsageCache: (cached: CachedSessionUsage, hasPricing: boolean, hasResolvableApiCostMessage: boolean) => boolean;
81
+ throwOnLoadFailure?: boolean;
82
+ };
83
+ /**
84
+ * Compute day/week/month history usage by paging through session messages.
85
+ *
86
+ * This is the shared core used by both the server-side `quota_summary` tool
87
+ * and the TUI-local `/qday /qweek /qmonth` popup.
88
+ *
89
+ * Callers inject their own `loadMessagesPage` implementation so the function
90
+ * does not depend on any specific runtime context.
91
+ */
92
+ export declare function computeHistoryUsage(deps: ComputeHistoryUsageDeps, period: HistoryPeriod, rawSince: string): Promise<HistoryUsageResult>;
93
+ export {};
@@ -0,0 +1,251 @@
1
+ import { parseSince, periodRanges, } from './period.js';
2
+ import { mapConcurrent } from './helpers.js';
3
+ import { accumulateMessagesAcrossCompletedRanges, accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
4
+ // ── Constants ──────────────────────────────────────────────────────────────
5
+ const RANGE_USAGE_CONCURRENCY = 5;
6
+ // ── Core ───────────────────────────────────────────────────────────────────
7
+ function filterRangeSessions(sessions, startAt, endAt) {
8
+ return sessions.filter((session) => {
9
+ if (session.state.createdAt > endAt)
10
+ return false;
11
+ if (session.state.dirty === true)
12
+ return true;
13
+ const lastMessageTime = session.state.cursor?.lastMessageTime;
14
+ if (typeof lastMessageTime === 'number' &&
15
+ Number.isFinite(lastMessageTime) &&
16
+ lastMessageTime < startAt) {
17
+ return false;
18
+ }
19
+ return true;
20
+ });
21
+ }
22
+ function pageLatestTimestamp(entries) {
23
+ let latest = Number.NEGATIVE_INFINITY;
24
+ for (const entry of entries) {
25
+ const info = entry.info;
26
+ const completed = info.time?.completed;
27
+ if (typeof completed === 'number' && Number.isFinite(completed)) {
28
+ if (completed > latest)
29
+ latest = completed;
30
+ continue;
31
+ }
32
+ const created = info.time?.created;
33
+ if (typeof created === 'number' && Number.isFinite(created)) {
34
+ if (created > latest)
35
+ latest = created;
36
+ }
37
+ }
38
+ return latest;
39
+ }
40
+ function rangeIndexForTimestamp(ranges, timestamp) {
41
+ let low = 0;
42
+ let high = ranges.length - 1;
43
+ while (low <= high) {
44
+ const mid = Math.floor((low + high) / 2);
45
+ const range = ranges[mid];
46
+ if (timestamp < range.startAt) {
47
+ high = mid - 1;
48
+ continue;
49
+ }
50
+ if (timestamp >= range.endAt) {
51
+ low = mid + 1;
52
+ continue;
53
+ }
54
+ return mid;
55
+ }
56
+ return -1;
57
+ }
58
+ function canUseCurrentSessionCache(cached, session, ranges) {
59
+ if (!cached)
60
+ return undefined;
61
+ if (cached.billingVersion !== USAGE_BILLING_CACHE_VERSION)
62
+ return undefined;
63
+ if (session.state.dirty === true)
64
+ return undefined;
65
+ const lastMessageTime = session.state.cursor?.lastMessageTime;
66
+ if (typeof lastMessageTime !== 'number' ||
67
+ !Number.isFinite(lastMessageTime)) {
68
+ return undefined;
69
+ }
70
+ if (typeof session.state.createdAt !== 'number' ||
71
+ !Number.isFinite(session.state.createdAt)) {
72
+ return undefined;
73
+ }
74
+ const startIndex = rangeIndexForTimestamp(ranges, session.state.createdAt);
75
+ const endIndex = rangeIndexForTimestamp(ranges, lastMessageTime);
76
+ if (startIndex < 0 || endIndex < 0 || startIndex !== endIndex) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ index: startIndex,
81
+ usage: fromCachedSessionUsage(cached, 1),
82
+ lastMessageTime,
83
+ };
84
+ }
85
+ /**
86
+ * Compute day/week/month history usage by paging through session messages.
87
+ *
88
+ * This is the shared core used by both the server-side `quota_summary` tool
89
+ * and the TUI-local `/qday /qweek /qmonth` popup.
90
+ *
91
+ * Callers inject their own `loadMessagesPage` implementation so the function
92
+ * does not depend on any specific runtime context.
93
+ */
94
+ export async function computeHistoryUsage(deps, period, rawSince) {
95
+ const now = Date.now();
96
+ const since = parseSince(rawSince, now);
97
+ const ranges = periodRanges(period, since, now);
98
+ const total = emptyUsageSummary();
99
+ const rows = ranges.map((range) => ({
100
+ range,
101
+ usage: emptyUsageSummary(),
102
+ }));
103
+ if (ranges.length === 0) {
104
+ return { period, since, rows, total };
105
+ }
106
+ const startAt = ranges[0].startAt;
107
+ const endAt = ranges[ranges.length - 1].endAt;
108
+ const sessions = filterRangeSessions(deps.sessions, startAt, endAt);
109
+ const modelCostMap = await deps.getModelCostMap();
110
+ const hasPricing = Object.keys(modelCostMap).length > 0;
111
+ if (sessions.length > 0) {
112
+ const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
113
+ const cachedHit = canUseCurrentSessionCache(session.state.usage, session, rows.map((row) => ({
114
+ startAt: row.range.startAt,
115
+ endAt: row.range.endAt,
116
+ })));
117
+ if (cachedHit) {
118
+ const rangeUsage = rows.map(() => emptyUsageSummary());
119
+ rangeUsage[cachedHit.index] = cachedHit.usage;
120
+ return {
121
+ sessionID: session.sessionID,
122
+ dateKey: session.dateKey,
123
+ lastMessageTime: cachedHit.lastMessageTime,
124
+ dirty: false,
125
+ ranges: rangeUsage,
126
+ totalUsage: cachedHit.usage,
127
+ fullUsage: undefined,
128
+ loadFailed: false,
129
+ missing: false,
130
+ persist: false,
131
+ cursor: session.state.cursor,
132
+ };
133
+ }
134
+ const usageOptions = {
135
+ calcApiCost: (message) => deps.calcApiCost(message, modelCostMap),
136
+ classifyCacheMode: (message) => deps.classifyCacheMode(message, modelCostMap),
137
+ };
138
+ const rangeUsage = rows.map(() => emptyUsageSummary());
139
+ const totalUsage = emptyUsageSummary();
140
+ const trackFullUsage = deps.shouldTrackFullUsage(session.state.usage, hasPricing);
141
+ const fullUsage = trackFullUsage ? emptyUsageSummary() : undefined;
142
+ let cursor;
143
+ let hasResolvable = false;
144
+ let before;
145
+ while (true) {
146
+ const load = await deps.loadMessagesPage(session.sessionID, before);
147
+ if (load.status !== 'ok') {
148
+ return {
149
+ sessionID: session.sessionID,
150
+ dateKey: session.dateKey,
151
+ lastMessageTime: session.state.cursor?.lastMessageTime,
152
+ dirty: session.state.dirty === true,
153
+ ranges: rows.map(() => emptyUsageSummary()),
154
+ totalUsage: emptyUsageSummary(),
155
+ fullUsage: undefined,
156
+ loadFailed: load.status === 'error',
157
+ missing: load.status === 'missing',
158
+ persist: false,
159
+ cursor: undefined,
160
+ };
161
+ }
162
+ const entries = load.entries;
163
+ if (entries.length === 0)
164
+ break;
165
+ accumulateMessagesAcrossCompletedRanges(rangeUsage, entries, rows.map((row) => ({
166
+ startAt: row.range.startAt,
167
+ endAt: row.range.endAt,
168
+ })), usageOptions);
169
+ if (fullUsage) {
170
+ accumulateMessagesInCompletedRange(fullUsage, entries, 0, Number.POSITIVE_INFINITY, usageOptions);
171
+ cursor = mergeCursorFromEntries(cursor, entries);
172
+ }
173
+ if (!hasResolvable) {
174
+ hasResolvable = deps.hasResolvableApiCostMessages(entries, modelCostMap);
175
+ }
176
+ // `session.messages(limit, before)` pages from newest to oldest.
177
+ // When we are only computing range usage (no full-session persistence),
178
+ // we can stop as soon as this page's newest timestamp is already older
179
+ // than the earliest requested range boundary.
180
+ if (!fullUsage) {
181
+ const latestInPage = pageLatestTimestamp(entries);
182
+ if (Number.isFinite(latestInPage) && latestInPage < startAt) {
183
+ break;
184
+ }
185
+ }
186
+ if (!load.nextBefore)
187
+ break;
188
+ before = load.nextBefore;
189
+ }
190
+ for (const item of rangeUsage) {
191
+ if (item.assistantMessages > 0) {
192
+ mergeUsage(totalUsage, item);
193
+ }
194
+ }
195
+ if (totalUsage.assistantMessages > 0) {
196
+ totalUsage.sessionCount = 1;
197
+ }
198
+ const shouldPersist = !!fullUsage &&
199
+ (!session.state.usage ||
200
+ deps.shouldRecomputeUsageCache(session.state.usage, hasPricing, hasResolvable));
201
+ return {
202
+ sessionID: session.sessionID,
203
+ dateKey: session.dateKey,
204
+ lastMessageTime: cursor?.lastMessageTime,
205
+ dirty: session.state.dirty === true,
206
+ ranges: rangeUsage,
207
+ totalUsage,
208
+ fullUsage: shouldPersist ? fullUsage : undefined,
209
+ loadFailed: false,
210
+ missing: false,
211
+ persist: shouldPersist,
212
+ cursor,
213
+ };
214
+ });
215
+ const failedLoads = fetched.filter((item) => {
216
+ if (!item.loadFailed)
217
+ return false;
218
+ if (item.dirty)
219
+ return true;
220
+ const lastMessageTime = item.lastMessageTime;
221
+ if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
222
+ return false;
223
+ }
224
+ return true;
225
+ });
226
+ if (failedLoads.length > 0 && deps.throwOnLoadFailure !== false) {
227
+ throw new Error(`history usage unavailable: failed to load ${failedLoads.length} session(s)`);
228
+ }
229
+ for (const item of fetched) {
230
+ for (let index = 0; index < rows.length; index++) {
231
+ if (item.ranges[index].assistantMessages > 0) {
232
+ mergeUsage(rows[index].usage, item.ranges[index]);
233
+ }
234
+ }
235
+ if (item.totalUsage.assistantMessages > 0) {
236
+ mergeUsage(total, item.totalUsage);
237
+ }
238
+ }
239
+ return {
240
+ period,
241
+ since,
242
+ rows,
243
+ total,
244
+ warning: deps.throwOnLoadFailure === false && failedLoads.length > 0
245
+ ? `Skipped ${failedLoads.length} session(s) that could not be loaded.`
246
+ : undefined,
247
+ persistenceHints: fetched,
248
+ };
249
+ }
250
+ return { period, since, rows, total };
251
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
1
+ import { renderHistoryMarkdownReport, 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';
@@ -11,6 +11,7 @@ import { createPersistenceScheduler } from './persistence.js';
11
11
  import { createQuotaService } from './quota_service.js';
12
12
  import { createUsageService } from './usage_service.js';
13
13
  import { createTitleApplicator } from './title_apply.js';
14
+ import { listCurrentProviderIDs } from './provider_catalog.js';
14
15
  const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
15
16
  const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
16
17
  const SESSION_ACTIVE_GRACE_MS = 15_000;
@@ -45,6 +46,7 @@ export async function QuotaSidebarPlugin(input) {
45
46
  scheduleSave,
46
47
  });
47
48
  const getQuotaSnapshots = quotaService.getQuotaSnapshots;
49
+ const invalidateQuotaForProvider = quotaService.invalidateForProvider;
48
50
  const ensureSessionState = (sessionID, title, createdAt = Date.now(), parentID) => {
49
51
  const existing = state.sessions[sessionID];
50
52
  if (existing) {
@@ -117,7 +119,9 @@ export async function QuotaSidebarPlugin(input) {
117
119
  });
118
120
  const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
119
121
  const summarizeForTool = usageService.summarizeForTool;
122
+ const summarizeHistoryForTool = usageService.summarizeHistoryUsage;
120
123
  const activeSessionUntil = new Map();
124
+ let lastTuiSessionID;
121
125
  const markSessionActive = (sessionID, now = Date.now()) => {
122
126
  activeSessionUntil.set(sessionID, now + SESSION_ACTIVE_GRACE_MS);
123
127
  };
@@ -219,6 +223,8 @@ export async function QuotaSidebarPlugin(input) {
219
223
  }
220
224
  }
221
225
  const showToast = async (period, message) => {
226
+ if (!input.client.tui?.showToast)
227
+ return;
222
228
  await input.client.tui
223
229
  .showToast({
224
230
  query: { directory: input.directory },
@@ -345,10 +351,16 @@ export async function QuotaSidebarPlugin(input) {
345
351
  scheduleActiveTitleRefresh(session.parentID, 0);
346
352
  }
347
353
  },
348
- onTuiActivity: async () => {
349
- return;
354
+ onTuiActivity: async (sessionID) => {
355
+ const target = sessionID || lastTuiSessionID;
356
+ if (!target)
357
+ return;
358
+ lastTuiSessionID = target;
359
+ markSessionActive(target);
350
360
  },
351
361
  onTuiSessionSelect: async (sessionID) => {
362
+ lastTuiSessionID = sessionID;
363
+ markSessionActive(sessionID);
352
364
  scheduleActiveTitleRefresh(sessionID, 0);
353
365
  },
354
366
  onMessageRemoved: async (info) => {
@@ -360,16 +372,23 @@ export async function QuotaSidebarPlugin(input) {
360
372
  const now = Date.now();
361
373
  const completed = message.time.completed;
362
374
  if (typeof completed !== 'number' || !Number.isFinite(completed)) {
375
+ const created = message.time.created;
376
+ if (typeof created === 'number' &&
377
+ Number.isFinite(created) &&
378
+ created < now - SESSION_ACTIVE_GRACE_MS) {
379
+ return;
380
+ }
363
381
  markSessionActive(message.sessionID, now);
364
382
  return;
365
383
  }
384
+ invalidateQuotaForProvider(message.providerID);
366
385
  const wasActive = isSessionActive(message.sessionID, now);
367
386
  if (!wasActive) {
368
387
  return;
369
388
  }
370
389
  markSessionActive(message.sessionID, now);
371
390
  usageService.markSessionDirty(message.sessionID);
372
- scheduleActiveTitleRefresh(message.sessionID);
391
+ scheduleActiveTitleRefresh(message.sessionID, 100);
373
392
  void maybeShowExpiryToast(message.sessionID);
374
393
  },
375
394
  });
@@ -399,9 +418,15 @@ export async function QuotaSidebarPlugin(input) {
399
418
  restoreSessionTitle: (sessionID) => titleApplicator.restoreSessionTitle(sessionID),
400
419
  showToast,
401
420
  summarizeForTool,
421
+ summarizeHistoryForTool,
422
+ listCurrentProviderIDs: () => listCurrentProviderIDs({
423
+ client: input.client,
424
+ directory: input.directory,
425
+ }),
402
426
  getQuotaSnapshots,
403
427
  renderMarkdownReport,
404
428
  renderToastMessage,
429
+ renderHistoryMarkdownReport,
405
430
  config: {
406
431
  sidebar: config.sidebar,
407
432
  sidebarEnabled: config.sidebar.enabled,
package/dist/period.d.ts CHANGED
@@ -1 +1,29 @@
1
- export declare function periodStart(period: 'day' | 'week' | 'month'): number;
1
+ export type HistoryPeriod = 'day' | 'week' | 'month';
2
+ export type SincePrecision = 'month' | 'day';
3
+ export type SinceSpec = {
4
+ raw: string;
5
+ precision: SincePrecision;
6
+ startAt: number;
7
+ };
8
+ export type PeriodRange = {
9
+ period: HistoryPeriod;
10
+ startAt: number;
11
+ endAt: number;
12
+ label: string;
13
+ shortLabel: string;
14
+ isCurrent: boolean;
15
+ isPartial: boolean;
16
+ index: number;
17
+ };
18
+ export declare function parseSince(raw: string, now?: number): {
19
+ raw: string;
20
+ precision: "month";
21
+ startAt: number;
22
+ } | {
23
+ raw: string;
24
+ precision: "day";
25
+ startAt: number;
26
+ };
27
+ export declare function periodRanges(period: HistoryPeriod, since: SinceSpec, endAt?: number): PeriodRange[];
28
+ export declare function periodStart(period: HistoryPeriod, now?: number): number;
29
+ export declare function sinceFromLast(period: HistoryPeriod, last: number, now?: number): string;