mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 00:18:40 +02:00
feat: surface Copilot usage and quota data across the UI
Add three layers of usage visibility: - ChatPane footer: premium request count, AIU consumed, and quota remaining below the existing context-window bar - Settings / CopilotStatusCard: on-demand account quota section with progress bars, overage indicators, and reset dates fetched via the new get-quota sidecar command - Activity Panel: per-agent token/cost/duration totals on each agent row and a Session Usage summary section between agents and timeline Backend (sidecar): - New get-quota command using SDK account.getQuota RPC - New assistant-usage turn-scoped event from SDK assistant.usage - QuotaSnapshotMapper for both typed and untyped SDK quota payloads - DTOs: GetQuotaCommandDto, QuotaSnapshotDto, AccountQuotaResultEventDto, AssistantUsageEventDto Frontend: - Shared types: AssistantUsageEvent, QuotaSnapshot, GetQuotaCommand - IPC bridge: getQuota channel, assistant-usage event dispatch - State: SessionRequestUsageMap accumulator with per-agent breakdown - 8 new tests for accumulator logic and formatting helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1297,6 +1297,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return queryWorkspaceSessions(workspace, input);
|
||||
}
|
||||
|
||||
async getQuota(): Promise<Record<string, import('@shared/contracts/sidecar').QuotaSnapshot>> {
|
||||
return this.sidecar.getQuota();
|
||||
}
|
||||
|
||||
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const projects = projectId
|
||||
@@ -1893,6 +1897,24 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
agentName: event.agentName,
|
||||
});
|
||||
return;
|
||||
case 'assistant-usage':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'assistant-usage',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
usageModel: event.model,
|
||||
usageInputTokens: event.inputTokens,
|
||||
usageOutputTokens: event.outputTokens,
|
||||
usageCacheReadTokens: event.cacheReadTokens,
|
||||
usageCacheWriteTokens: event.cacheWriteTokens,
|
||||
usageCost: event.cost,
|
||||
usageDuration: event.duration,
|
||||
usageTotalNanoAiu: event.totalNanoAiu,
|
||||
usageQuotaSnapshots: event.quotaSnapshots,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
||||
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
|
||||
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
|
||||
ipcMain.handle(ipcChannels.getQuota, () => service.getQuota());
|
||||
|
||||
service.on('workspace-updated', (workspace) => {
|
||||
window.webContents.send(ipcChannels.workspaceUpdated, workspace);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionUsageEvent,
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
AssistantUsageEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
@@ -20,7 +21,8 @@ export type TurnScopedEvent =
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent;
|
||||
| PendingMessagesModifiedEvent
|
||||
| AssistantUsageEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
QuotaSnapshot,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
@@ -80,6 +81,12 @@ type PendingCommand =
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'get-quota';
|
||||
resolve: (snapshots: Record<string, QuotaSnapshot>) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
} & RunTurnPendingCommand);
|
||||
@@ -185,6 +192,13 @@ export class SidecarClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getQuota(): Promise<Record<string, QuotaSnapshot>> {
|
||||
return this.dispatch<Record<string, QuotaSnapshot>>({
|
||||
type: 'get-quota',
|
||||
requestId: `get-quota-${Date.now()}`,
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const state = this.processState;
|
||||
if (!state) {
|
||||
@@ -341,6 +355,13 @@ export class SidecarClient {
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'get-quota') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'get-quota',
|
||||
resolve: resolve as (snapshots: Record<string, QuotaSnapshot>) => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -424,10 +445,17 @@ export class SidecarClient {
|
||||
case 'session-usage':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'assistant-usage':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
return;
|
||||
case 'quota-result':
|
||||
if (pending.kind === 'get-quota') {
|
||||
pending.resolve(event.quotaSnapshots);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'sessions-listed':
|
||||
if (pending.kind === 'list-sessions') {
|
||||
pending.resolve(event.sessions);
|
||||
|
||||
@@ -65,6 +65,7 @@ const api: ElectronApi = {
|
||||
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
||||
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
|
||||
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
|
||||
getQuota: () => ipcRenderer.invoke(ipcChannels.getQuota),
|
||||
onTerminalData: (listener) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: Parameters<typeof listener>[0]) =>
|
||||
listener(data);
|
||||
|
||||
@@ -13,12 +13,15 @@ import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
applySessionUsageEvent,
|
||||
applyAssistantUsageEvent,
|
||||
applyTurnEventLog,
|
||||
pruneSessionActivities,
|
||||
pruneSessionUsage,
|
||||
pruneSessionRequestUsage,
|
||||
pruneTurnEventLogs,
|
||||
type SessionActivityMap,
|
||||
type SessionUsageMap,
|
||||
type SessionRequestUsageMap,
|
||||
type TurnEventLogMap,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
|
||||
@@ -101,6 +104,7 @@ export default function App() {
|
||||
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
|
||||
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
||||
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||
|
||||
@@ -140,6 +144,12 @@ export default function App() {
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
setSessionRequestUsage((current) =>
|
||||
pruneSessionRequestUsage(
|
||||
current,
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
setTurnEventLogs((current) =>
|
||||
pruneTurnEventLogs(
|
||||
current,
|
||||
@@ -158,6 +168,7 @@ export default function App() {
|
||||
setWorkspace((current) => applySessionEventWorkspace(current, event));
|
||||
setSessionActivities((current) => applySessionEventActivity(current, event));
|
||||
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
||||
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||
setActiveSubagents((current) => applySubagentEvent(current, event));
|
||||
});
|
||||
@@ -214,6 +225,10 @@ export default function App() {
|
||||
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionUsage],
|
||||
);
|
||||
const requestUsageForSession = useMemo(
|
||||
() => (selectedSession ? sessionRequestUsage[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionRequestUsage],
|
||||
);
|
||||
const subagentsForSession = useMemo(
|
||||
() => (selectedSession ? activeSubagents[selectedSession.id] : undefined),
|
||||
[selectedSession, activeSubagents],
|
||||
@@ -401,6 +416,7 @@ export default function App() {
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={terminalOpen}
|
||||
terminalRunning={terminalRunning}
|
||||
@@ -413,6 +429,7 @@ export default function App() {
|
||||
onJumpToMessage={jumpToMessage}
|
||||
pattern={patternForSession}
|
||||
session={selectedSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
turnEvents={turnEventsForSession}
|
||||
/>
|
||||
);
|
||||
@@ -478,6 +495,7 @@ export default function App() {
|
||||
onResolveUserDiscoveredTooling={(serverIds, resolution) => {
|
||||
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
|
||||
}}
|
||||
onGetQuota={() => api.getQuota()}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
formatAgentActivityLabel,
|
||||
formatDuration,
|
||||
formatNanoAiu,
|
||||
formatTokenCount,
|
||||
isAgentActivityActive,
|
||||
isAgentActivityCompleted,
|
||||
type AgentActivityRow,
|
||||
type AgentUsageAccumulator,
|
||||
type SessionActivityState,
|
||||
type SessionRequestUsageState,
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
@@ -70,11 +75,13 @@ function AgentRow({
|
||||
agent,
|
||||
accent,
|
||||
isLast,
|
||||
agentUsage,
|
||||
}: {
|
||||
row: AgentActivityRow;
|
||||
agent?: PatternAgentDefinition;
|
||||
accent: (typeof modeAccent)[OrchestrationMode];
|
||||
isLast: boolean;
|
||||
agentUsage?: AgentUsageAccumulator;
|
||||
}) {
|
||||
const isActive = isAgentActivityActive(row.activity);
|
||||
const isCompleted = isAgentActivityCompleted(row.activity);
|
||||
@@ -141,6 +148,27 @@ function AgentRow({
|
||||
{formatAgentActivityLabel(row.activity)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-agent usage summary */}
|
||||
{agentUsage && agentUsage.requestCount > 0 && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-zinc-600">
|
||||
<span className="tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
|
||||
{agentUsage.cost > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
|
||||
</>
|
||||
)}
|
||||
{agentUsage.durationMs > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -182,6 +210,7 @@ interface ActivityPanelProps {
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
sessionRequestUsage?: SessionRequestUsageState;
|
||||
turnEvents?: TurnEventLog;
|
||||
}
|
||||
|
||||
@@ -190,6 +219,7 @@ export function ActivityPanel({
|
||||
onJumpToMessage,
|
||||
pattern,
|
||||
session,
|
||||
sessionRequestUsage,
|
||||
turnEvents,
|
||||
}: ActivityPanelProps) {
|
||||
const activityRows = useMemo(
|
||||
@@ -241,21 +271,68 @@ export function ActivityPanel({
|
||||
|
||||
{activityRows.length > 0 ? (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3">
|
||||
{activityRows.map((row, index) => (
|
||||
<AgentRow
|
||||
accent={accent}
|
||||
agent={pattern.agents[index]}
|
||||
isLast={index === activityRows.length - 1}
|
||||
key={row.key}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
{activityRows.map((row, index) => {
|
||||
const agentKey = row.activity?.agentId ?? row.key;
|
||||
const agentUsage = sessionRequestUsage?.perAgent[agentKey]
|
||||
?? sessionRequestUsage?.perAgent[row.agentName];
|
||||
return (
|
||||
<AgentRow
|
||||
accent={accent}
|
||||
agent={pattern.agents[index]}
|
||||
agentUsage={agentUsage}
|
||||
isLast={index === activityRows.length - 1}
|
||||
key={row.key}
|
||||
row={row}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-[11px] text-zinc-600">No agents configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Session usage section ──────────────────────────── */}
|
||||
{sessionRequestUsage && sessionRequestUsage.requestCount > 0 && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<BarChart3 className="size-3" />
|
||||
<span>Session Usage</span>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-zinc-400">
|
||||
<span className="font-medium tabular-nums">
|
||||
{sessionRequestUsage.requestCount} premium request{sessionRequestUsage.requestCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{sessionRequestUsage.totalNanoAiu > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatNanoAiu(sessionRequestUsage.totalNanoAiu)} AIU</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[10px] text-zinc-500">
|
||||
<span className="tabular-nums">{formatTokenCount(sessionRequestUsage.totalInputTokens)} in</span>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatTokenCount(sessionRequestUsage.totalOutputTokens)} out</span>
|
||||
{sessionRequestUsage.totalCost > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{sessionRequestUsage.totalCost.toFixed(2)} cost</span>
|
||||
</>
|
||||
)}
|
||||
{sessionRequestUsage.totalDurationMs > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatDuration(sessionRequestUsage.totalDurationMs)} total</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Run timeline section ─────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import type { SessionRequestUsageState, AgentUsageAccumulator } from '@renderer/lib/sessionActivity';
|
||||
import { formatNanoAiu } from '@renderer/lib/sessionActivity';
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
import {
|
||||
findModel,
|
||||
@@ -46,6 +48,7 @@ interface ChatPaneProps {
|
||||
mcpProbingServerIds?: string[];
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
sessionRequestUsage?: SessionRequestUsageState;
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
@@ -75,6 +78,7 @@ export function ChatPane({
|
||||
mcpProbingServerIds,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
sessionRequestUsage,
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
@@ -777,6 +781,27 @@ export function ChatPane({
|
||||
{Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context
|
||||
</span>
|
||||
</div>
|
||||
{sessionRequestUsage && sessionRequestUsage.requestCount > 0 && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-zinc-600">
|
||||
<span className="tabular-nums">
|
||||
{sessionRequestUsage.requestCount} premium request{sessionRequestUsage.requestCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{sessionRequestUsage.totalNanoAiu > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">{formatNanoAiu(sessionRequestUsage.totalNanoAiu)} AIU</span>
|
||||
</>
|
||||
)}
|
||||
{sessionRequestUsage.latestQuotaSnapshots?.premium_interactions && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.round(sessionRequestUsage.latestQuotaSnapshots.premium_interactions.remainingPercentage)}% quota
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
ArrowUpCircle,
|
||||
User,
|
||||
Building2,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
SidecarConnectionDiagnostics,
|
||||
SidecarConnectionStatus,
|
||||
SidecarCopilotCliVersionStatus,
|
||||
QuotaSnapshot,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
interface CopilotStatusCardProps {
|
||||
@@ -25,6 +28,7 @@ interface CopilotStatusCardProps {
|
||||
modelCount: number;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
@@ -125,6 +129,137 @@ function VersionBadge({ status, installedVersion }: { status: SidecarCopilotCliV
|
||||
}
|
||||
}
|
||||
|
||||
const quotaTypeLabels: Record<string, string> = {
|
||||
premium_interactions: 'Premium Requests',
|
||||
chat: 'Chat',
|
||||
completions: 'Completions',
|
||||
};
|
||||
|
||||
function formatQuotaTypeLabel(key: string): string {
|
||||
return quotaTypeLabels[key] ?? key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function formatResetDate(iso: string): string {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMs / 86_400_000);
|
||||
|
||||
if (diffDays <= 0) return 'Today';
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays <= 30) return `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function QuotaSection({
|
||||
onGetQuota,
|
||||
}: {
|
||||
onGetQuota: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}) {
|
||||
const [quotaData, setQuotaData] = useState<Record<string, QuotaSnapshot>>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
void onGetQuota()
|
||||
.then((data) => { if (!cancelled) setQuotaData(data); })
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [onGetQuota]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="size-3.5 animate-spin text-zinc-500" />
|
||||
<span className="text-[12px] text-zinc-500">Loading quota…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<XCircle className="size-3.5 text-red-400" />
|
||||
<span className="text-[12px] text-zinc-500">Could not load quota</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quotaData || Object.keys(quotaData).length === 0) {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<span className="text-[12px] text-zinc-600">No quota data available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(quotaData).map(([key, snapshot]) => {
|
||||
const usedPct = snapshot.entitlementRequests > 0
|
||||
? (snapshot.usedRequests / snapshot.entitlementRequests) * 100
|
||||
: 0;
|
||||
const barColor = usedPct > 90
|
||||
? 'bg-red-500'
|
||||
: usedPct > 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-indigo-500/60';
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-zinc-300">
|
||||
{formatQuotaTypeLabel(key)}
|
||||
</span>
|
||||
<span className="text-[11px] tabular-nums text-zinc-500">
|
||||
{Math.round(snapshot.remainingPercentage)}% remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${Math.min(100, usedPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-zinc-500">
|
||||
<span className="tabular-nums">
|
||||
{Math.round(snapshot.usedRequests)} of {Math.round(snapshot.entitlementRequests)} used
|
||||
</span>
|
||||
{snapshot.overage > 0 && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="tabular-nums text-amber-400">
|
||||
{Math.round(snapshot.overage)} overage
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{snapshot.resetDate && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span>Resets {formatResetDate(snapshot.resetDate)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSection({ connection }: { connection: SidecarConnectionDiagnostics }) {
|
||||
const { account } = connection;
|
||||
if (!account) return null;
|
||||
@@ -182,6 +317,7 @@ export function CopilotStatusCard({
|
||||
modelCount,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onGetQuota,
|
||||
}: CopilotStatusCardProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
@@ -242,6 +378,19 @@ export function CopilotStatusCard({
|
||||
<AccountSection connection={connection} />
|
||||
)}
|
||||
|
||||
{/* Usage & Quota (when healthy and callback provided) */}
|
||||
{isHealthy && onGetQuota && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-400">
|
||||
<BarChart3 className="size-3" />
|
||||
<span>Usage & Quota</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/40 px-3 py-2.5">
|
||||
<QuotaSection onGetQuota={onGetQuota} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action hint for non-ready states */}
|
||||
{!isHealthy && config.actionLabel && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2.5">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
@@ -42,6 +42,7 @@ interface SettingsPanelProps {
|
||||
onOpenAppDataFolder: () => void;
|
||||
onResetLocalWorkspace: () => Promise<void>;
|
||||
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}
|
||||
|
||||
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
@@ -119,6 +120,7 @@ export function SettingsPanel({
|
||||
onOpenAppDataFolder,
|
||||
onResetLocalWorkspace,
|
||||
onResolveUserDiscoveredTooling,
|
||||
onGetQuota,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
@@ -261,6 +263,7 @@ export function SettingsPanel({
|
||||
isRefreshing={isRefreshingCapabilities}
|
||||
modelCount={sidecarCapabilities?.models.length ?? 0}
|
||||
onRefresh={onRefreshCapabilities}
|
||||
onGetQuota={onGetQuota}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'patterns' && (
|
||||
@@ -365,11 +368,13 @@ function ConnectionSection({
|
||||
modelCount,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onGetQuota,
|
||||
}: {
|
||||
connection?: SidecarCapabilities['connection'];
|
||||
modelCount: number;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -384,6 +389,7 @@ function ConnectionSection({
|
||||
connection={connection}
|
||||
isRefreshing={isRefreshing}
|
||||
modelCount={modelCount}
|
||||
onGetQuota={onGetQuota}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
|
||||
export interface AgentActivityState {
|
||||
agentId: string;
|
||||
@@ -335,3 +336,119 @@ export function pruneTurnEventLogs(
|
||||
|
||||
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||
}
|
||||
|
||||
/* ── Assistant usage accumulator ────────────────────────────── */
|
||||
|
||||
export interface AgentUsageAccumulator {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cost: number;
|
||||
durationMs: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface SessionRequestUsageState {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCost: number;
|
||||
totalDurationMs: number;
|
||||
totalNanoAiu: number;
|
||||
requestCount: number;
|
||||
perAgent: Record<string, AgentUsageAccumulator>;
|
||||
latestQuotaSnapshots?: Record<string, QuotaSnapshot>;
|
||||
}
|
||||
|
||||
export type SessionRequestUsageMap = Record<string, SessionRequestUsageState | undefined>;
|
||||
|
||||
function createEmptyUsageAccumulator(): AgentUsageAccumulator {
|
||||
return { inputTokens: 0, outputTokens: 0, cost: 0, durationMs: 0, requestCount: 0 };
|
||||
}
|
||||
|
||||
export function applyAssistantUsageEvent(
|
||||
current: SessionRequestUsageMap,
|
||||
event: SessionEventRecord,
|
||||
): SessionRequestUsageMap {
|
||||
if (event.kind !== 'assistant-usage') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const prev = current[event.sessionId] ?? {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
totalDurationMs: 0,
|
||||
totalNanoAiu: 0,
|
||||
requestCount: 0,
|
||||
perAgent: {},
|
||||
};
|
||||
|
||||
const inputTokens = event.usageInputTokens ?? 0;
|
||||
const outputTokens = event.usageOutputTokens ?? 0;
|
||||
const cost = event.usageCost ?? 0;
|
||||
const durationMs = event.usageDuration ?? 0;
|
||||
const nanoAiu = event.usageTotalNanoAiu ?? 0;
|
||||
|
||||
const next: SessionRequestUsageState = {
|
||||
totalInputTokens: prev.totalInputTokens + inputTokens,
|
||||
totalOutputTokens: prev.totalOutputTokens + outputTokens,
|
||||
totalCost: prev.totalCost + cost,
|
||||
totalDurationMs: prev.totalDurationMs + durationMs,
|
||||
totalNanoAiu: nanoAiu > 0 ? nanoAiu : prev.totalNanoAiu,
|
||||
requestCount: prev.requestCount + 1,
|
||||
perAgent: { ...prev.perAgent },
|
||||
latestQuotaSnapshots: event.usageQuotaSnapshots ?? prev.latestQuotaSnapshots,
|
||||
};
|
||||
|
||||
const agentKey = event.agentId?.trim() || event.agentName?.trim();
|
||||
if (agentKey) {
|
||||
const agentPrev = next.perAgent[agentKey] ?? createEmptyUsageAccumulator();
|
||||
next.perAgent[agentKey] = {
|
||||
inputTokens: agentPrev.inputTokens + inputTokens,
|
||||
outputTokens: agentPrev.outputTokens + outputTokens,
|
||||
cost: agentPrev.cost + cost,
|
||||
durationMs: agentPrev.durationMs + durationMs,
|
||||
requestCount: agentPrev.requestCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...current, [event.sessionId]: next };
|
||||
}
|
||||
|
||||
export function pruneSessionRequestUsage(
|
||||
current: SessionRequestUsageMap,
|
||||
sessionIds: Iterable<string>,
|
||||
): SessionRequestUsageMap {
|
||||
const allowed = new Set(sessionIds);
|
||||
const next: SessionRequestUsageMap = {};
|
||||
let changed = false;
|
||||
|
||||
for (const [sessionId, usage] of Object.entries(current)) {
|
||||
if (!allowed.has(sessionId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[sessionId] = usage;
|
||||
}
|
||||
|
||||
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||
}
|
||||
|
||||
/* ── Formatting helpers ─────────────────────────────────────── */
|
||||
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
export function formatNanoAiu(nanoAiu: number): string {
|
||||
const aiu = nanoAiu / 1e9;
|
||||
if (aiu >= 100) return aiu.toFixed(0);
|
||||
if (aiu >= 10) return aiu.toFixed(1);
|
||||
return aiu.toFixed(2);
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`;
|
||||
return `${(ms / 1_000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
@@ -52,4 +52,5 @@ export const ipcChannels = {
|
||||
terminalExit: 'terminal:exit',
|
||||
workspaceUpdated: 'workspace:updated',
|
||||
sessionEvent: 'sessions:event',
|
||||
getQuota: 'sidecar:get-quota',
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||
@@ -202,6 +202,7 @@ export interface ElectronApi {
|
||||
resizeTerminal(input: ResizeTerminalInput): void;
|
||||
openAppDataFolder(): Promise<void>;
|
||||
resetLocalWorkspace(): Promise<WorkspaceState>;
|
||||
getQuota(): Promise<Record<string, QuotaSnapshot>>;
|
||||
onTerminalData(listener: (data: string) => void): () => void;
|
||||
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
|
||||
|
||||
@@ -144,7 +144,8 @@ export type SidecarCommand =
|
||||
| ResolveUserInputCommand
|
||||
| ListSessionsCommand
|
||||
| DeleteSessionCommand
|
||||
| DisconnectSessionCommand;
|
||||
| DisconnectSessionCommand
|
||||
| GetQuotaCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
id: string;
|
||||
@@ -470,6 +471,43 @@ export interface CommandErrorEvent {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AssistantUsageEvent {
|
||||
type: 'assistant-usage';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
model: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
totalNanoAiu?: number;
|
||||
quotaSnapshots?: Record<string, QuotaSnapshot>;
|
||||
}
|
||||
|
||||
export interface QuotaSnapshot {
|
||||
entitlementRequests: number;
|
||||
usedRequests: number;
|
||||
remainingPercentage: number;
|
||||
overage: number;
|
||||
overageAllowedWithExhaustedQuota: boolean;
|
||||
resetDate?: string;
|
||||
}
|
||||
|
||||
export interface GetQuotaCommand {
|
||||
type: 'get-quota';
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface QuotaResultEvent {
|
||||
type: 'quota-result';
|
||||
requestId: string;
|
||||
quotaSnapshots: Record<string, QuotaSnapshot>;
|
||||
}
|
||||
|
||||
export interface CommandCompleteEvent {
|
||||
type: 'command-complete';
|
||||
requestId: string;
|
||||
@@ -494,5 +532,7 @@ export type SidecarEvent =
|
||||
| SessionsListedEvent
|
||||
| SessionsDeletedEvent
|
||||
| SessionDisconnectedEvent
|
||||
| AssistantUsageEvent
|
||||
| QuotaResultEvent
|
||||
| CommandErrorEvent
|
||||
| CommandCompleteEvent;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
|
||||
import type { QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
|
||||
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
|
||||
export type SessionEventKind =
|
||||
@@ -14,7 +16,8 @@ export type SessionEventKind =
|
||||
| 'hook-lifecycle'
|
||||
| 'session-usage'
|
||||
| 'session-compaction'
|
||||
| 'pending-messages-modified';
|
||||
| 'pending-messages-modified'
|
||||
| 'assistant-usage';
|
||||
|
||||
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
||||
|
||||
@@ -67,4 +70,15 @@ export interface SessionEventRecord {
|
||||
preCompactionTokens?: number;
|
||||
postCompactionTokens?: number;
|
||||
tokensRemoved?: number;
|
||||
|
||||
// Assistant usage fields
|
||||
usageModel?: string;
|
||||
usageInputTokens?: number;
|
||||
usageOutputTokens?: number;
|
||||
usageCacheReadTokens?: number;
|
||||
usageCacheWriteTokens?: number;
|
||||
usageCost?: number;
|
||||
usageDuration?: number;
|
||||
usageTotalNanoAiu?: number;
|
||||
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user