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:
David Kaya
2026-03-29 00:46:09 +01:00
co-authored by Copilot
parent 48efbf36f9
commit 92832c6116
26 changed files with 978 additions and 18 deletions
+22
View File
@@ -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;
}
}
+1
View File
@@ -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);
+3 -1
View File
@@ -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';
+28
View File
@@ -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);
+1
View File
@@ -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);
+18
View File
@@ -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;
+87 -10
View File
@@ -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>
+25
View File
@@ -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>
+150 -1
View File
@@ -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 &amp; 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">
+7 -1
View File
@@ -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>
+117
View File
@@ -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`;
}
+1
View File
@@ -52,4 +52,5 @@ export const ipcChannels = {
terminalExit: 'terminal:exit',
workspaceUpdated: 'workspace:updated',
sessionEvent: 'sessions:event',
getQuota: 'sidecar:get-quota',
} as const;
+2 -1
View File
@@ -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;
+41 -1
View File
@@ -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;
+15 -1
View File
@@ -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>;
}