From 92832c6116acb4a9ae5fed8aaadf06eef8b2129f Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 00:46:09 +0100 Subject: [PATCH] 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> --- ARCHITECTURE.md | 6 +- .../Contracts/ProtocolModels.cs | 33 ++++ .../Services/CopilotSessionManager.cs | 9 ++ .../Services/CopilotTurnExecutionState.cs | 27 ++++ .../Services/ICopilotSessionManager.cs | 3 + .../Services/QuotaSnapshotMapper.cs | 105 ++++++++++++ .../Services/SidecarProtocolHost.cs | 19 +++ .../CopilotTurnExecutionStateTests.cs | 63 ++++++++ .../SidecarProtocolHostTests.cs | 47 ++++++ src/main/AryxAppService.ts | 22 +++ src/main/ipc/registerIpcHandlers.ts | 1 + src/main/sidecar/runTurnPending.ts | 4 +- src/main/sidecar/sidecarProcess.ts | 28 ++++ src/preload/index.ts | 1 + src/renderer/App.tsx | 18 +++ src/renderer/components/ActivityPanel.tsx | 97 +++++++++-- src/renderer/components/ChatPane.tsx | 25 +++ src/renderer/components/CopilotStatusCard.tsx | 151 +++++++++++++++++- src/renderer/components/SettingsPanel.tsx | 8 +- src/renderer/lib/sessionActivity.ts | 117 ++++++++++++++ src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 3 +- src/shared/contracts/sidecar.ts | 42 ++++- src/shared/domain/event.ts | 16 +- tests/renderer/sessionActivity.test.ts | 148 +++++++++++++++++ website/src/pages/index.astro | 2 +- 26 files changed, 978 insertions(+), 18 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Services/QuotaSnapshotMapper.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c6d8350..e41e14f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -200,6 +200,7 @@ The integrated terminal uses the same boundary. The renderer never opens a shell This is a structured stdio protocol used for: - capability discovery +- on-demand account quota lookup - pattern validation - run execution - streaming partial output @@ -212,12 +213,15 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt - **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined - **Skill invocation events**: emitted when an agent-side skill is triggered - **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity +- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming -- **Session usage events**: current token count and context-window limit for usage-bar rendering +- **Session usage events**: current token count and context-window limit from `session.usage_info` for context-bar rendering - **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace. +The same boundary also supports server-scoped sidecar commands that do not require a live Copilot session. The new `get-quota` command uses the SDK's `account.getQuota` RPC to fetch account quota snapshots on demand, then returns them as a `quota-result` protocol event followed by the usual `command-complete` sentinel. + For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy. The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from repo-level instruction files and merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance. diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index bf8b30d..ff8a313 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -224,6 +224,8 @@ public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope public string SessionId { get; init; } = string.Empty; } +public sealed class GetQuotaCommandDto : SidecarCommandEnvelope; + public sealed class RunTurnToolingConfigDto { public IReadOnlyList McpServers { get; init; } = []; @@ -386,6 +388,37 @@ public sealed class HookLifecycleEventDto : SidecarEventDto public string? Error { get; init; } } +public sealed class QuotaSnapshotDto +{ + public double EntitlementRequests { get; init; } + public double UsedRequests { get; init; } + public double RemainingPercentage { get; init; } + public double Overage { get; init; } + public bool OverageAllowedWithExhaustedQuota { get; init; } + public string? ResetDate { get; init; } +} + +public sealed class AccountQuotaResultEventDto : SidecarEventDto +{ + public Dictionary QuotaSnapshots { get; init; } = new(StringComparer.Ordinal); +} + +public sealed class AssistantUsageEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string Model { get; init; } = string.Empty; + public double? InputTokens { get; init; } + public double? OutputTokens { get; init; } + public double? CacheReadTokens { get; init; } + public double? CacheWriteTokens { get; init; } + public double? Cost { get; init; } + public double? Duration { get; init; } + public double? TotalNanoAiu { get; init; } + public Dictionary? QuotaSnapshots { get; init; } +} + public sealed class SessionUsageEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs index f8a1e50..65bdf38 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs @@ -1,10 +1,19 @@ using Aryx.AgentHost.Contracts; using GitHub.Copilot.SDK; +using GitHub.Copilot.SDK.Rpc; namespace Aryx.AgentHost.Services; internal sealed class CopilotSessionManager : ICopilotSessionManager { + public async Task> GetQuotaAsync( + CancellationToken cancellationToken) + { + await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false); + AccountGetQuotaResult result = await client.Rpc.Account.GetQuotaAsync(cancellationToken).ConfigureAwait(false); + return QuotaSnapshotMapper.Map(result.QuotaSnapshots); + } + public async Task> ListSessionsAsync( CopilotSessionListFilterDto? filter, CancellationToken cancellationToken) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index bf09373..89f6d63 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -127,6 +127,10 @@ internal sealed class CopilotTurnExecutionState _pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data)); } break; + case AssistantUsageEvent assistantUsage: + ActiveAgent = agent; + _pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.Data)); + break; case SessionUsageInfoEvent usageInfo: ActiveAgent = agent; _pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data)); @@ -410,6 +414,29 @@ internal sealed class CopilotTurnExecutionState }; } + private AssistantUsageEventDto CreateAssistantUsageEvent( + AgentIdentity agent, + AssistantUsageData? data) + { + return new AssistantUsageEventDto + { + Type = "assistant-usage", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + AgentId = agent.AgentId, + AgentName = agent.AgentName, + Model = data?.Model ?? string.Empty, + InputTokens = data?.InputTokens, + OutputTokens = data?.OutputTokens, + CacheReadTokens = data?.CacheReadTokens, + CacheWriteTokens = data?.CacheWriteTokens, + Cost = data?.Cost, + Duration = data?.Duration, + TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu, + QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots), + }; + } + private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data) { return new SessionUsageEventDto diff --git a/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs index 297592e..e8b6b39 100644 --- a/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs +++ b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs @@ -12,5 +12,8 @@ public interface ICopilotSessionManager string? aryxSessionId, string? copilotSessionId, CancellationToken cancellationToken); + + Task> GetQuotaAsync( + CancellationToken cancellationToken); } diff --git a/sidecar/src/Aryx.AgentHost/Services/QuotaSnapshotMapper.cs b/sidecar/src/Aryx.AgentHost/Services/QuotaSnapshotMapper.cs new file mode 100644 index 0000000..e4f6f09 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/QuotaSnapshotMapper.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Aryx.AgentHost.Contracts; +using GitHub.Copilot.SDK.Rpc; + +namespace Aryx.AgentHost.Services; + +internal static class QuotaSnapshotMapper +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + public static Dictionary Map( + IReadOnlyDictionary? snapshots) + { + Dictionary mapped = new(StringComparer.Ordinal); + if (snapshots is null) + { + return mapped; + } + + foreach ((string key, AccountGetQuotaResultQuotaSnapshotsValue snapshot) in snapshots) + { + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + mapped[key.Trim()] = Map(snapshot); + } + + return mapped; + } + + public static Dictionary? MapOrNull( + IReadOnlyDictionary? snapshots) + { + if (snapshots is not { Count: > 0 }) + { + return null; + } + + Dictionary mapped = new(StringComparer.Ordinal); + foreach ((string key, object snapshot) in snapshots) + { + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + QuotaSnapshotDto? mappedSnapshot = TryMap(snapshot); + if (mappedSnapshot is null) + { + continue; + } + + mapped[key.Trim()] = mappedSnapshot; + } + + return mapped.Count == 0 ? null : mapped; + } + + public static QuotaSnapshotDto Map(AccountGetQuotaResultQuotaSnapshotsValue snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new QuotaSnapshotDto + { + EntitlementRequests = snapshot.EntitlementRequests, + UsedRequests = snapshot.UsedRequests, + RemainingPercentage = snapshot.RemainingPercentage, + Overage = snapshot.Overage, + OverageAllowedWithExhaustedQuota = snapshot.OverageAllowedWithExhaustedQuota, + ResetDate = snapshot.ResetDate, + }; + } + + private static QuotaSnapshotDto? TryMap(object? snapshot) + { + if (snapshot is null) + { + return null; + } + + if (snapshot is AccountGetQuotaResultQuotaSnapshotsValue typedSnapshot) + { + return Map(typedSnapshot); + } + + JsonElement element = snapshot is JsonElement jsonElement + ? jsonElement + : JsonSerializer.SerializeToElement(snapshot, JsonOptions); + + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + AccountGetQuotaResultQuotaSnapshotsValue? deserialized = + element.Deserialize(JsonOptions); + + return deserialized is null ? null : Map(deserialized); + } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index 2ec89d4..27c7e40 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -18,6 +18,7 @@ public sealed class SidecarProtocolHost private const string ListSessionsCommandType = "list-sessions"; private const string DeleteSessionCommandType = "delete-session"; private const string DisconnectSessionCommandType = "disconnect-session"; + private const string GetQuotaCommandType = "get-quota"; private const string AskUserToolName = "ask_user"; private static readonly HashSet ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase) { @@ -82,6 +83,7 @@ public sealed class SidecarProtocolHost [ListSessionsCommandType] = HandleListSessionsAsync, [DeleteSessionCommandType] = HandleDeleteSessionAsync, [DisconnectSessionCommandType] = HandleDisconnectSessionAsync, + [GetQuotaCommandType] = HandleGetQuotaAsync, }; } @@ -313,6 +315,23 @@ public sealed class SidecarProtocolHost }, context.CancellationToken).ConfigureAwait(false); } + private async Task HandleGetQuotaAsync(CommandContext context) + { + _ = DeserializeCommand(context); + IReadOnlyDictionary quotaSnapshots = + await _sessionManager.GetQuotaAsync(context.CancellationToken).ConfigureAwait(false); + + await WriteAsync(context.Output, new AccountQuotaResultEventDto + { + Type = "quota-result", + RequestId = context.Envelope.RequestId, + QuotaSnapshots = quotaSnapshots.ToDictionary( + snapshot => snapshot.Key, + snapshot => snapshot.Value, + StringComparer.Ordinal), + }, context.CancellationToken).ConfigureAwait(false); + } + private TCommand DeserializeCommand(CommandContext context) where TCommand : SidecarCommandEnvelope { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index 93ff92b..98dca66 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -251,6 +251,69 @@ public sealed class CopilotTurnExecutionStateTests Assert.Empty(state.DrainPendingEvents()); } + [Fact] + public void ObserveSessionEvent_AssistantUsage_QueuesAssistantUsageEvent() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.usage", + "data": { + "model": "gpt-5.4", + "inputTokens": 1200, + "outputTokens": 300, + "cacheReadTokens": 50, + "cacheWriteTokens": 10, + "cost": 0.42, + "duration": 8200, + "quotaSnapshots": { + "premium_interactions": { + "entitlementRequests": 50, + "usedRequests": 12, + "remainingPercentage": 76, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "resetDate": "2026-04-01T00:00:00Z" + } + }, + "copilotUsage": { + "tokenDetails": [ + { + "batchSize": 1, + "costPerBatch": 1, + "tokenCount": 1500, + "tokenType": "input" + } + ], + "totalNanoAiu": 1200000000 + } + }, + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + AssistantUsageEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); + Assert.Equal("session-1", evt.SessionId); + Assert.Equal("agent-1", evt.AgentId); + Assert.Equal("Primary", evt.AgentName); + Assert.Equal("gpt-5.4", evt.Model); + Assert.Equal(1200, evt.InputTokens); + Assert.Equal(300, evt.OutputTokens); + Assert.Equal(0.42, evt.Cost); + Assert.Equal(8200, evt.Duration); + Assert.Equal(1200000000, evt.TotalNanoAiu); + QuotaSnapshotDto snapshot = Assert.Single(evt.QuotaSnapshots!.Values); + Assert.Equal(50, snapshot.EntitlementRequests); + Assert.Equal(12, snapshot.UsedRequests); + Assert.Equal(76, snapshot.RemainingPercentage); + } + [Fact] public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent() { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 5e42cfb..021e5b3 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -850,6 +850,44 @@ public sealed class SidecarProtocolHostTests Assert.Equal("session-1", sessionManager.DeletedAryxSessionId); } + [Fact] + public async Task GetQuotaCommand_ReturnsQuotaResultEvent() + { + SidecarProtocolHost host = new( + new PatternValidator(), + sessionManager: new FakeSessionManager + { + QuotaSnapshots = new Dictionary(StringComparer.Ordinal) + { + ["premium_interactions"] = new() + { + EntitlementRequests = 50, + UsedRequests = 12, + RemainingPercentage = 76, + Overage = 0, + OverageAllowedWithExhaustedQuota = true, + ResetDate = "2026-04-01T00:00:00Z", + }, + }, + }); + + IReadOnlyList events = await RunHostAsync( + new GetQuotaCommandDto + { + Type = "get-quota", + RequestId = "quota-1", + }, + host); + + JsonElement quotaEvent = AssertSingleEvent(events, "quota-result", "quota-1"); + JsonElement snapshot = quotaEvent.GetProperty("quotaSnapshots").GetProperty("premium_interactions"); + Assert.Equal(50, snapshot.GetProperty("entitlementRequests").GetDouble()); + Assert.Equal(12, snapshot.GetProperty("usedRequests").GetDouble()); + Assert.Equal(76, snapshot.GetProperty("remainingPercentage").GetDouble()); + Assert.True(snapshot.GetProperty("overageAllowedWithExhaustedQuota").GetBoolean()); + Assert.Equal("2026-04-01T00:00:00Z", snapshot.GetProperty("resetDate").GetString()); + } + [Fact] public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession() { @@ -1115,6 +1153,9 @@ public sealed class SidecarProtocolHostTests public IReadOnlyList DeletedSessions { get; init; } = []; + public IReadOnlyDictionary QuotaSnapshots { get; init; } + = new Dictionary(StringComparer.Ordinal); + public string? DeletedAryxSessionId { get; private set; } public string? DeletedCopilotSessionId { get; private set; } @@ -1135,5 +1176,11 @@ public sealed class SidecarProtocolHostTests DeletedCopilotSessionId = copilotSessionId; return Task.FromResult(DeletedSessions); } + + public Task> GetQuotaAsync( + CancellationToken cancellationToken) + { + return Task.FromResult(QuotaSnapshots); + } } } diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index bc6db5c..720cf89 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -1297,6 +1297,10 @@ export class AryxAppService extends EventEmitter { return queryWorkspaceSessions(workspace, input); } + async getQuota(): Promise> { + return this.sidecar.getQuota(); + } + async refreshProjectGitContext(projectId?: string): Promise { const workspace = await this.loadWorkspace(); const projects = projectId @@ -1893,6 +1897,24 @@ export class AryxAppService extends EventEmitter { 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; } } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 0d8cc15..e94d69c 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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); diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index a8ec574..2b2f4d5 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -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'; diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 680d6a0..96593e9 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -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) => void; + reject: (error: Error) => void; + }) | ({ processId: number; } & RunTurnPendingCommand); @@ -185,6 +192,13 @@ export class SidecarClient { }); } + async getQuota(): Promise> { + return this.dispatch>({ + type: 'get-quota', + requestId: `get-quota-${Date.now()}`, + }); + } + async dispose(): Promise { 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) => 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); diff --git a/src/preload/index.ts b/src/preload/index.ts index dc7e6d5..65d8d5e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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[0]) => listener(data); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 64e14fb..6bc8a81 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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({}); const [sessionUsage, setSessionUsage] = useState({}); + const [sessionRequestUsage, setSessionRequestUsage] = useState({}); const [turnEventLogs, setTurnEventLogs] = useState({}); const [activeSubagents, setActiveSubagents] = useState({}); @@ -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; diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index fc39071..677f482 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -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)} + + {/* Per-agent usage summary */} + {agentUsage && agentUsage.requestCount > 0 && ( +
+ {formatTokenCount(agentUsage.inputTokens)} in + · + {formatTokenCount(agentUsage.outputTokens)} out + {agentUsage.cost > 0 && ( + <> + · + {agentUsage.cost.toFixed(2)} cost + + )} + {agentUsage.durationMs > 0 && ( + <> + · + {formatDuration(agentUsage.durationMs)} + + )} +
+ )} ); @@ -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 ? (
- {activityRows.map((row, index) => ( - - ))} + {activityRows.map((row, index) => { + const agentKey = row.activity?.agentId ?? row.key; + const agentUsage = sessionRequestUsage?.perAgent[agentKey] + ?? sessionRequestUsage?.perAgent[row.agentName]; + return ( + + ); + })}
) : (

No agents configured

)} + {/* ── Session usage section ──────────────────────────── */} + {sessionRequestUsage && sessionRequestUsage.requestCount > 0 && ( +
+ + + Session Usage + + +
+
+ + {sessionRequestUsage.requestCount} premium request{sessionRequestUsage.requestCount === 1 ? '' : 's'} + + {sessionRequestUsage.totalNanoAiu > 0 && ( + <> + · + {formatNanoAiu(sessionRequestUsage.totalNanoAiu)} AIU + + )} +
+
+ {formatTokenCount(sessionRequestUsage.totalInputTokens)} in + · + {formatTokenCount(sessionRequestUsage.totalOutputTokens)} out + {sessionRequestUsage.totalCost > 0 && ( + <> + · + {sessionRequestUsage.totalCost.toFixed(2)} cost + + )} + {sessionRequestUsage.totalDurationMs > 0 && ( + <> + · + {formatDuration(sessionRequestUsage.totalDurationMs)} total + + )} +
+
+
+ )} + {/* ── Run timeline section ─────────────────────────── */}
diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index d9e1f86..77b7d37 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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; sessionUsage?: SessionUsageState; + sessionRequestUsage?: SessionRequestUsageState; activeSubagents?: ReadonlyArray; 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
+ {sessionRequestUsage && sessionRequestUsage.requestCount > 0 && ( +
+ + {sessionRequestUsage.requestCount} premium request{sessionRequestUsage.requestCount === 1 ? '' : 's'} + + {sessionRequestUsage.totalNanoAiu > 0 && ( + <> + · + {formatNanoAiu(sessionRequestUsage.totalNanoAiu)} AIU + + )} + {sessionRequestUsage.latestQuotaSnapshots?.premium_interactions && ( + <> + · + + {Math.round(sessionRequestUsage.latestQuotaSnapshots.premium_interactions.remainingPercentage)}% quota + + + )} +
+ )} )} diff --git a/src/renderer/components/CopilotStatusCard.tsx b/src/renderer/components/CopilotStatusCard.tsx index 5acab19..27f957c 100644 --- a/src/renderer/components/CopilotStatusCard.tsx +++ b/src/renderer/components/CopilotStatusCard.tsx @@ -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>; } interface StatusConfig { @@ -125,6 +129,137 @@ function VersionBadge({ status, installedVersion }: { status: SidecarCopilotCliV } } +const quotaTypeLabels: Record = { + 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>; +}) { + const [quotaData, setQuotaData] = useState>(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + 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 ( +
+ + Loading quota… +
+ ); + } + + if (error) { + return ( +
+ + Could not load quota +
+ ); + } + + if (!quotaData || Object.keys(quotaData).length === 0) { + return ( +
+ No quota data available +
+ ); + } + + return ( +
+ {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 ( +
+
+ + {formatQuotaTypeLabel(key)} + + + {Math.round(snapshot.remainingPercentage)}% remaining + +
+
+
+
+
+ + {Math.round(snapshot.usedRequests)} of {Math.round(snapshot.entitlementRequests)} used + + {snapshot.overage > 0 && ( + <> + · + + {Math.round(snapshot.overage)} overage + + + )} + {snapshot.resetDate && ( + <> + · + Resets {formatResetDate(snapshot.resetDate)} + + )} +
+
+ ); + })} +
+ ); +} + 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({ )} + {/* Usage & Quota (when healthy and callback provided) */} + {isHealthy && onGetQuota && ( +
+
+ + Usage & Quota +
+
+ +
+
+ )} + {/* Action hint for non-ready states */} {!isHealthy && config.actionLabel && (
diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 71b5b37..d1d88ad 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -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; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onGetQuota?: () => Promise>; } 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('appearance'); const [editingPattern, setEditingPattern] = useState(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>; }) { return (
@@ -384,6 +389,7 @@ function ConnectionSection({ connection={connection} isRefreshing={isRefreshing} modelCount={modelCount} + onGetQuota={onGetQuota} onRefresh={onRefresh} />
diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts index 1a69efb..6a3f489 100644 --- a/src/renderer/lib/sessionActivity.ts +++ b/src/renderer/lib/sessionActivity.ts @@ -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; + latestQuotaSnapshots?: Record; +} + +export type SessionRequestUsageMap = Record; + +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, +): 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`; +} diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 15b3435..7a806af 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -52,4 +52,5 @@ export const ipcChannels = { terminalExit: 'terminal:exit', workspaceUpdated: 'workspace:updated', sessionEvent: 'sessions:event', + getQuota: 'sidecar:get-quota', } as const; diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index cf22cab..7d391f0 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; resetLocalWorkspace(): Promise; + getQuota(): Promise>; onTerminalData(listener: (data: string) => void): () => void; onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 87aee7e..fd6fce9 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -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; +} + +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; +} + export interface CommandCompleteEvent { type: 'command-complete'; requestId: string; @@ -494,5 +532,7 @@ export type SidecarEvent = | SessionsListedEvent | SessionsDeletedEvent | SessionDisconnectedEvent + | AssistantUsageEvent + | QuotaResultEvent | CommandErrorEvent | CommandCompleteEvent; diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 01a4e33..802fb00 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -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; } diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts index b7217a1..00aaef5 100644 --- a/tests/renderer/sessionActivity.test.ts +++ b/tests/renderer/sessionActivity.test.ts @@ -2,12 +2,18 @@ import { describe, expect, test } from 'bun:test'; import { applySessionEventActivity, + applyAssistantUsageEvent, buildAgentActivityRows, formatAgentActivityLabel, + formatDuration, + formatNanoAiu, + formatTokenCount, isAgentActivityActive, isAgentActivityCompleted, pruneSessionActivities, + pruneSessionRequestUsage, type SessionActivityMap, + type SessionRequestUsageMap, } from '@renderer/lib/sessionActivity'; import type { PatternDefinition } from '@shared/domain/pattern'; import type { SessionEventRecord } from '@shared/domain/event'; @@ -379,3 +385,145 @@ describe('session activity helpers', () => { }); }); }); + +describe('assistant usage accumulator', () => { + function makeUsageEvent(overrides: Partial = {}): SessionEventRecord { + return { + sessionId: 'session-1', + kind: 'assistant-usage', + occurredAt: '2026-03-23T00:00:00.000Z', + agentId: 'architect', + agentName: 'Architect', + usageModel: 'gpt-5.4', + usageInputTokens: 1200, + usageOutputTokens: 300, + usageCost: 0.42, + usageDuration: 8200, + usageTotalNanoAiu: 1_200_000_000, + ...overrides, + }; + } + + test('accumulates session-level totals from assistant-usage events', () => { + let state: SessionRequestUsageMap = {}; + state = applyAssistantUsageEvent(state, makeUsageEvent()); + state = applyAssistantUsageEvent(state, makeUsageEvent({ + usageInputTokens: 800, + usageOutputTokens: 200, + usageCost: 0.28, + usageDuration: 5000, + usageTotalNanoAiu: 2_400_000_000, + })); + + const usage = state['session-1']!; + expect(usage.requestCount).toBe(2); + expect(usage.totalInputTokens).toBe(2000); + expect(usage.totalOutputTokens).toBe(500); + expect(usage.totalCost).toBeCloseTo(0.70); + expect(usage.totalDurationMs).toBe(13200); + expect(usage.totalNanoAiu).toBe(2_400_000_000); + }); + + test('accumulates per-agent totals keyed by agentId', () => { + let state: SessionRequestUsageMap = {}; + state = applyAssistantUsageEvent(state, makeUsageEvent({ agentId: 'architect' })); + state = applyAssistantUsageEvent(state, makeUsageEvent({ + agentId: 'reviewer', + agentName: 'Reviewer', + usageInputTokens: 500, + usageOutputTokens: 100, + usageCost: 0.10, + usageDuration: 3000, + })); + state = applyAssistantUsageEvent(state, makeUsageEvent({ + agentId: 'architect', + usageInputTokens: 600, + usageOutputTokens: 150, + usageCost: 0.20, + usageDuration: 4000, + })); + + const usage = state['session-1']!; + expect(usage.perAgent['architect']!.requestCount).toBe(2); + expect(usage.perAgent['architect']!.inputTokens).toBe(1800); + expect(usage.perAgent['reviewer']!.requestCount).toBe(1); + expect(usage.perAgent['reviewer']!.inputTokens).toBe(500); + }); + + test('ignores non-assistant-usage events', () => { + const state: SessionRequestUsageMap = {}; + const result = applyAssistantUsageEvent(state, { + sessionId: 'session-1', + kind: 'session-usage', + occurredAt: '2026-03-23T00:00:00.000Z', + tokenLimit: 100000, + currentTokens: 5000, + }); + expect(result).toBe(state); + }); + + test('stores latest quota snapshots', () => { + const snapshots = { + premium_interactions: { + entitlementRequests: 50, + usedRequests: 12, + remainingPercentage: 76, + overage: 0, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-04-01T00:00:00Z', + }, + }; + + let state: SessionRequestUsageMap = {}; + state = applyAssistantUsageEvent(state, makeUsageEvent({ usageQuotaSnapshots: snapshots })); + + expect(state['session-1']!.latestQuotaSnapshots).toEqual(snapshots); + }); + + test('prunes request usage for removed sessions', () => { + const current: SessionRequestUsageMap = { + 'session-1': { + totalInputTokens: 1000, + totalOutputTokens: 200, + totalCost: 0.3, + totalDurationMs: 5000, + totalNanoAiu: 1_000_000_000, + requestCount: 1, + perAgent: {}, + }, + 'session-2': { + totalInputTokens: 500, + totalOutputTokens: 100, + totalCost: 0.1, + totalDurationMs: 2000, + totalNanoAiu: 500_000_000, + requestCount: 1, + perAgent: {}, + }, + }; + + const pruned = pruneSessionRequestUsage(current, ['session-2']); + expect(pruned).toEqual({ 'session-2': current['session-2'] }); + expect(pruned).not.toBe(current); + }); +}); + +describe('usage formatting helpers', () => { + test('formatTokenCount formats values at different scales', () => { + expect(formatTokenCount(500)).toBe('500'); + expect(formatTokenCount(1200)).toBe('1.2k'); + expect(formatTokenCount(45300)).toBe('45.3k'); + expect(formatTokenCount(1_500_000)).toBe('1.5M'); + }); + + test('formatNanoAiu converts nano-AIU to human-readable', () => { + expect(formatNanoAiu(420_000_000)).toBe('0.42'); + expect(formatNanoAiu(12_300_000_000)).toBe('12.3'); + expect(formatNanoAiu(150_000_000_000)).toBe('150'); + }); + + test('formatDuration formats milliseconds', () => { + expect(formatDuration(8200)).toBe('8.2s'); + expect(formatDuration(150_000)).toBe('2.5m'); + }); +}); diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index f8d4400..eba0586 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -141,7 +141,7 @@ import Base from '../layouts/Base.astro';

Live Visibility

- Watch each agent think, delegate to sub-agents, invoke skills, and run hooks in real time. A context-usage bar shows how much of the model's window you've used. + Watch each agent think, delegate to sub-agents, invoke skills, and run hooks in real time. Track premium request consumption, per-agent token usage, and account quota remaining alongside the context-window bar.