Skip to content

Commit fde5d0d

Browse files
luoxuanzaoQoder-AI
andauthored
feat: add credits usage panel mirroring the Qoder IDE usage view (#21)
* feat: add credits usage panel mirroring the Qoder IDE usage view - Startup probe piggybacks getUsageInfo() so the runtime catalog caches account usage; a gauge button next to the session history opens a dropup panel with plan quota, personal/add-on pack and org resource package, each with a segmented progress bar - Personal accounts render like the IDE: plain tier label instead of the org badge, no renewal line for the year-9999 sentinel expiry - "View Details" links to the edition account usage page (qoder.com vs qoder.com.cn); panel width tracks the footer so narrow sidebars clip neither edge - 10 locales plus unit tests for the button, service, probe and catalog Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix: anchor toolbar dropdowns to logical edges - Model dropdown opens above the model button (inset-inline-start on the toolbar) instead of flying to the far edge on wide views, a regression from the narrow-sidebar adaptivity change - Permission dropdown switches right to inset-inline-end so both dropdowns anchor correctly in right-to-left layouts while keeping the adaptive max-width Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * docs: note credits usage panel and dropdown anchoring in changelog Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> --------- Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent b306137 commit fde5d0d

28 files changed

Lines changed: 988 additions & 7 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1313

1414
### Added
1515

16+
- Credits usage panel in the chat view: a gauge button next to the
17+
session history opens a usage popover that mirrors the Qoder IDE
18+
view (plan credits with edition badge, personal/add-on resource
19+
pack, organization resource package, renewal date) and links to the
20+
edition's account usage page.
1621
- Qoder CLI edition switch in settings (Setup section): choose the
1722
international build (`qodercli`, config under `~/.qoder`) or the
1823
China build (`qoderclicn`, config under `~/.qoder-cn`). Auto-
@@ -21,6 +26,10 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2126

2227
### Fixed
2328

29+
- The model selector dropdown opens above the model button again on
30+
wide views instead of anchoring to the far edge of the toolbar, and
31+
toolbar dropdowns anchor with logical edges so right-to-left
32+
layouts position correctly.
2433
- The composer now adapts to narrow sidebars: context chips that do
2534
not fit collapse behind a "+N more" pill (click to expand or
2635
collapse), the toolbar wraps instead of clipping, and the permission

src/core/types/services.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,29 @@ export interface AppPluginManager {
5050
disablePlugin(pluginId: string): Promise<void>;
5151
}
5252

53+
/** One quota bucket of the account credits usage snapshot. */
54+
export interface CreditsUsageQuota {
55+
total?: number;
56+
used?: number;
57+
remaining?: number;
58+
percentage?: number;
59+
}
60+
61+
/**
62+
* Account credits usage snapshot as reported by the Qoder SDK.
63+
* Mirrors the SDK `UsageInfo` shape without coupling core types to the SDK.
64+
*/
65+
export interface CreditsUsageSnapshot {
66+
userType?: string;
67+
totalUsagePercentage?: number;
68+
expiresAt?: number;
69+
upgradeUrl?: string;
70+
isQuotaExceeded?: boolean;
71+
userQuota?: CreditsUsageQuota;
72+
addOnQuota?: CreditsUsageQuota;
73+
orgResourcePackage?: CreditsUsageQuota & { cap?: number; available?: boolean };
74+
}
75+
5376
/** Runtime catalog of agents discovered from the Qoder CLI. */
5477
export interface AppAgentCatalog extends AgentMentionIndex {
5578
/**
@@ -66,6 +89,8 @@ export interface AppAgentCatalog extends AgentMentionIndex {
6689
getRuntimeStatus(): QoderRuntimeStatus;
6790
/** Receives runtime availability changes, including background refreshes. */
6891
subscribeRuntimeStatus(listener: (status: QoderRuntimeStatus) => void): () => void;
92+
/** Latest account credits usage from the last successful probe, if any. */
93+
getUsageInfo(): CreditsUsageSnapshot | null;
6994
}
7095

7196
export type QoderRuntimeStatusKind =

src/features/chat/chat-view.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ItemView, Notice, Scope, setIcon } from 'obsidian';
33

44
import { VIEW_TYPE_QODERIAN } from '../../core/types';
55
import type QoderianPlugin from '../../main';
6+
import { fetchCreditsUsage } from '../../qoder/services/credits-usage';
67
import {
78
cancelScheduledAnimationFrame,
89
scheduleAnimationFrame,
@@ -16,6 +17,7 @@ import {
1617
import { TabBar } from './tabs/tab-bar';
1718
import { TabManager } from './tabs/tab-manager';
1819
import type { TabData, TabId } from './tabs/types';
20+
import { CreditsUsageButton } from './ui/credits-usage-button';
1921

2022
type LoadableView = {
2123
containerEl?: HTMLElement;
@@ -43,6 +45,7 @@ export class QoderianView extends ItemView {
4345

4446
// Header elements
4547
private historyDropdown: HTMLElement | null = null;
48+
private creditsUsageButton: CreditsUsageButton | null = null;
4649

4750
// Event refs for cleanup
4851
private eventRefs: EventRef[] = [];
@@ -198,6 +201,9 @@ export class QoderianView extends ItemView {
198201

199202
this.tabBar?.destroy();
200203
this.tabBar = null;
204+
205+
this.creditsUsageButton?.destroy();
206+
this.creditsUsageButton = null;
201207
this.scope = null;
202208
}
203209

@@ -264,6 +270,14 @@ export class QoderianView extends ItemView {
264270
this.toggleHistoryDropdown();
265271
});
266272

273+
// Credits usage popover (account-level, shared across tabs)
274+
const agentCatalog = this.plugin.qoderServices.agentCatalog;
275+
this.creditsUsageButton = new CreditsUsageButton(navActionsEl, {
276+
getCachedUsage: () => agentCatalog.getUsageInfo(),
277+
fetchUsage: () => fetchCreditsUsage(this.plugin),
278+
subscribeRuntimeStatus: (listener) => agentCatalog.subscribeRuntimeStatus(listener),
279+
});
280+
267281
return wrapper;
268282
}
269283

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { setIcon } from 'obsidian';
2+
3+
import type {
4+
CreditsUsageQuota,
5+
CreditsUsageSnapshot,
6+
QoderRuntimeStatus,
7+
} from '../../../core/types/services';
8+
import { getLocale, t } from '../../../i18n/i18n';
9+
import { getQoderAccountUsageUrl } from '../../../qoder/config/cli-edition';
10+
import { ClickPopover } from './toolbar/click-popover';
11+
12+
/** Usage snapshots older than this are refreshed when the panel opens. */
13+
const USAGE_CACHE_TTL_MS = 60_000;
14+
15+
export interface CreditsUsageButtonCallbacks {
16+
/** Latest snapshot known to the runtime catalog (from the startup probe). */
17+
getCachedUsage: () => CreditsUsageSnapshot | null;
18+
/** Runs a dedicated idle query against the CLI; null when unavailable. */
19+
fetchUsage: () => Promise<CreditsUsageSnapshot | null>;
20+
subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void;
21+
}
22+
23+
function formatCount(value: number | undefined): string {
24+
return String(value ?? 0);
25+
}
26+
27+
function formatRenewalDate(timestamp: number): string {
28+
try {
29+
return new Date(timestamp).toLocaleDateString(getLocale(), {
30+
year: 'numeric',
31+
month: 'short',
32+
day: 'numeric',
33+
});
34+
} catch {
35+
return new Date(timestamp).toDateString();
36+
}
37+
}
38+
39+
/** IDE-style tier label: org editions get the green pill, personal tiers plain text. */
40+
function describeUserTier(userType: string | undefined): { text: string; plain: boolean } | null {
41+
if (userType === 'teams') return { text: 'Teams', plain: false };
42+
if (userType === 'personal_standard') return { text: t('credits.tierTrial'), plain: true };
43+
return null;
44+
}
45+
46+
/** The server uses a year-9999 timestamp for plans that never renew. */
47+
function isSentinelExpiry(timestamp: number): boolean {
48+
return new Date(timestamp).getUTCFullYear() >= 9999;
49+
}
50+
51+
/**
52+
* Nav-row button showing overall credits usage with a popover panel that
53+
* mirrors the Qoder IDE usage view: plan quota, add-on quota and the
54+
* organization resource package, each with a segmented progress bar.
55+
*/
56+
export class CreditsUsageButton {
57+
private readonly container: HTMLElement;
58+
private readonly buttonEl: HTMLElement;
59+
private readonly panelEl: HTMLElement;
60+
private readonly popover: ClickPopover;
61+
private readonly unsubscribeRuntimeStatus: (() => void) | null;
62+
private refreshBtnEl: HTMLElement | null = null;
63+
private snapshot: CreditsUsageSnapshot | null;
64+
private fetchedAt = 0;
65+
private loading = false;
66+
67+
constructor(parentEl: HTMLElement, private readonly callbacks: CreditsUsageButtonCallbacks) {
68+
this.container = parentEl.createDiv({ cls: 'qoderian-credits-container' });
69+
this.buttonEl = this.container.createDiv({ cls: 'qoderian-input-nav-btn qoderian-credits-btn' });
70+
setIcon(this.buttonEl, 'gauge');
71+
72+
this.panelEl = this.container.createDiv({ cls: 'qoderian-credits-panel' });
73+
this.popover = new ClickPopover(
74+
this.container,
75+
this.buttonEl,
76+
this.panelEl,
77+
'qoderian-credits--open',
78+
);
79+
this.buttonEl.addEventListener('click', this.handleButtonClick);
80+
81+
this.snapshot = callbacks.getCachedUsage();
82+
this.unsubscribeRuntimeStatus = callbacks.subscribeRuntimeStatus?.((status) => {
83+
this.snapshot = callbacks.getCachedUsage() ?? this.snapshot;
84+
this.updateButton();
85+
this.renderPanel();
86+
if (status.kind === 'ready' && !this.snapshot) void this.refresh(false);
87+
}) ?? null;
88+
89+
this.updateButton();
90+
this.renderPanel();
91+
}
92+
93+
destroy(): void {
94+
this.unsubscribeRuntimeStatus?.();
95+
this.buttonEl.removeEventListener('click', this.handleButtonClick);
96+
this.popover.destroy();
97+
this.container.remove();
98+
}
99+
100+
/** Fetches a fresh snapshot; cached snapshots within the TTL are kept. */
101+
async refresh(force: boolean): Promise<void> {
102+
if (this.loading) return;
103+
if (!force && this.snapshot && Date.now() - this.fetchedAt < USAGE_CACHE_TTL_MS) return;
104+
105+
this.loading = true;
106+
this.refreshBtnEl?.addClass('qoderian-credits-refresh--spinning');
107+
try {
108+
const snapshot = await this.callbacks.fetchUsage();
109+
if (snapshot) {
110+
this.snapshot = snapshot;
111+
this.fetchedAt = Date.now();
112+
}
113+
} finally {
114+
this.loading = false;
115+
this.updateButton();
116+
this.renderPanel();
117+
}
118+
}
119+
120+
private readonly handleButtonClick = (): void => {
121+
// ClickPopover's own handler runs first and flips aria-expanded.
122+
if (this.buttonEl.getAttribute('aria-expanded') === 'true') {
123+
void this.refresh(false);
124+
}
125+
};
126+
127+
private updateButton(): void {
128+
const percent = this.snapshot?.totalUsagePercentage;
129+
if (typeof percent === 'number') {
130+
this.buttonEl.setAttribute('title', t('credits.trigger', { percent: Math.round(percent) }));
131+
} else {
132+
this.buttonEl.setAttribute('title', t('credits.unavailable'));
133+
}
134+
}
135+
136+
private renderPanel(): void {
137+
this.panelEl.empty();
138+
139+
const header = this.panelEl.createDiv({ cls: 'qoderian-credits-header' });
140+
header.createSpan({ cls: 'qoderian-credits-title', text: t('credits.title') });
141+
const actions = header.createDiv({ cls: 'qoderian-credits-header-actions' });
142+
actions.createEl('a', {
143+
cls: 'qoderian-credits-details-link',
144+
text: t('credits.viewDetails'),
145+
attr: { href: getQoderAccountUsageUrl() },
146+
});
147+
this.refreshBtnEl = actions.createDiv({ cls: 'qoderian-credits-refresh' });
148+
setIcon(this.refreshBtnEl, 'refresh-cw');
149+
this.refreshBtnEl.setAttribute('role', 'button');
150+
this.refreshBtnEl.setAttribute('tabindex', '0');
151+
this.refreshBtnEl.setAttribute('aria-label', t('common.refresh'));
152+
if (this.loading) this.refreshBtnEl.addClass('qoderian-credits-refresh--spinning');
153+
this.refreshBtnEl.addEventListener('click', (event) => {
154+
event.stopPropagation();
155+
void this.refresh(true);
156+
});
157+
158+
if (!this.snapshot) {
159+
this.panelEl.createDiv({ cls: 'qoderian-credits-empty', text: t('credits.unavailable') });
160+
return;
161+
}
162+
163+
if (this.snapshot.userQuota) {
164+
const tier = describeUserTier(this.snapshot.userType);
165+
this.renderQuotaSection(this.snapshot.userQuota, {
166+
title: t('credits.planCredits'),
167+
...(tier ? { badge: tier.text, badgePlain: tier.plain } : {}),
168+
...(typeof this.snapshot.expiresAt === 'number' && !isSentinelExpiry(this.snapshot.expiresAt)
169+
? { trailing: t('credits.renewsOn', { date: formatRenewalDate(this.snapshot.expiresAt) }) }
170+
: {}),
171+
});
172+
}
173+
if (this.snapshot.addOnQuota && hasQuotaNumbers(this.snapshot.addOnQuota)) {
174+
this.renderQuotaSection(this.snapshot.addOnQuota, { title: t('credits.addOnCredits') });
175+
}
176+
const orgPackage = this.snapshot.orgResourcePackage;
177+
if (orgPackage?.available && hasQuotaNumbers(orgPackage)) {
178+
this.renderQuotaSection({
179+
total: orgPackage.cap,
180+
used: orgPackage.used,
181+
remaining: orgPackage.remaining,
182+
percentage: orgPackage.percentage,
183+
}, { title: t('credits.resourcePackage') });
184+
}
185+
}
186+
187+
private renderQuotaSection(
188+
quota: CreditsUsageQuota,
189+
options: { title: string; badge?: string; badgePlain?: boolean; trailing?: string },
190+
): void {
191+
const section = this.panelEl.createDiv({ cls: 'qoderian-credits-section' });
192+
193+
const head = section.createDiv({ cls: 'qoderian-credits-section-head' });
194+
const titleWrap = head.createDiv({ cls: 'qoderian-credits-section-title-wrap' });
195+
titleWrap.createSpan({ cls: 'qoderian-credits-section-title', text: options.title });
196+
if (options.badge) {
197+
const badge = titleWrap.createSpan({ cls: 'qoderian-credits-badge', text: options.badge });
198+
if (options.badgePlain) badge.addClass('qoderian-credits-badge--plain');
199+
}
200+
if (options.trailing) {
201+
head.createSpan({ cls: 'qoderian-credits-trailing', text: options.trailing });
202+
}
203+
204+
const percent = clampPercent(quota);
205+
const bar = section.createDiv({ cls: 'qoderian-credits-bar' });
206+
const fill = bar.createDiv({ cls: 'qoderian-credits-bar-fill' });
207+
fill.style.width = `${percent}%`;
208+
209+
const numbers = section.createDiv({ cls: 'qoderian-credits-numbers' });
210+
const usageNums = numbers.createDiv({ cls: 'qoderian-credits-usage-nums' });
211+
usageNums.createSpan({ cls: 'qoderian-credits-used-num', text: formatCount(quota.used) });
212+
usageNums.createSpan({
213+
cls: 'qoderian-credits-total-num',
214+
text: ` / ${formatCount(quota.total)} `,
215+
});
216+
usageNums.createSpan({
217+
cls: 'qoderian-credits-used-percent',
218+
text: `(${t('credits.usedPercent', { percent })})`,
219+
});
220+
numbers.createSpan({
221+
cls: 'qoderian-credits-left',
222+
text: t('credits.left', { count: formatCount(quota.remaining) }),
223+
});
224+
}
225+
}
226+
227+
function hasQuotaNumbers(quota: CreditsUsageQuota): boolean {
228+
return typeof quota.total === 'number' || typeof quota.used === 'number';
229+
}
230+
231+
function clampPercent(quota: CreditsUsageQuota): number {
232+
const derived = typeof quota.total === 'number' && quota.total > 0
233+
? ((quota.used ?? 0) / quota.total) * 100
234+
: 0;
235+
const percent = Math.round(quota.percentage ?? derived);
236+
return Math.min(100, Math.max(0, percent));
237+
}

src/i18n/locales/de.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,18 @@
274274
"name": "Sprache",
275275
"desc": "Anzeigesprache der Plugin-Oberfläche ändern"
276276
}
277+
},
278+
"credits": {
279+
"title": "Credit-Nutzung",
280+
"viewDetails": "Details ansehen",
281+
"planCredits": "Plan-Credits",
282+
"addOnCredits": "Persönliches Ressourcenpaket",
283+
"resourcePackage": "Add-on-Credits",
284+
"tierTrial": "Testversion",
285+
"renewsOn": "Verlängert am {date}",
286+
"usedPercent": "{percent}% verwendet",
287+
"left": "{count} übrig",
288+
"trigger": "Nutzung - {percent}%",
289+
"unavailable": "Nutzung nicht verfügbar"
277290
}
278291
}

src/i18n/locales/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,18 @@
274274
"name": "Language",
275275
"desc": "Change the display language of the plugin interface"
276276
}
277+
},
278+
"credits": {
279+
"title": "Credits Usage",
280+
"viewDetails": "View Details",
281+
"planCredits": "Plan Credits",
282+
"addOnCredits": "Personal Resource Pack",
283+
"resourcePackage": "Add-on Credits",
284+
"tierTrial": "Trial",
285+
"renewsOn": "Renews on {date}",
286+
"usedPercent": "{percent}% used",
287+
"left": "{count} left",
288+
"trigger": "Usage - {percent}%",
289+
"unavailable": "Usage unavailable"
277290
}
278291
}

0 commit comments

Comments
 (0)