mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +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:
+5
-1
@@ -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.
|
||||
|
||||
@@ -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<RunTurnMcpServerConfigDto> 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<string, QuotaSnapshotDto> 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<string, QuotaSnapshotDto>? QuotaSnapshots { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
|
||||
@@ -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<IReadOnlyDictionary<string, QuotaSnapshotDto>> 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<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,8 @@ public interface ICopilotSessionManager
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, QuotaSnapshotDto> Map(
|
||||
IReadOnlyDictionary<string, AccountGetQuotaResultQuotaSnapshotsValue>? snapshots)
|
||||
{
|
||||
Dictionary<string, QuotaSnapshotDto> 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<string, QuotaSnapshotDto>? MapOrNull(
|
||||
IReadOnlyDictionary<string, object>? snapshots)
|
||||
{
|
||||
if (snapshots is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, QuotaSnapshotDto> 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<AccountGetQuotaResultQuotaSnapshotsValue>(JsonOptions);
|
||||
|
||||
return deserialized is null ? null : Map(deserialized);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<GetQuotaCommandDto>(context);
|
||||
IReadOnlyDictionary<string, QuotaSnapshotDto> 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<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
|
||||
@@ -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<AssistantUsageEventDto>());
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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<string, QuotaSnapshotDto>(StringComparer.Ordinal)
|
||||
{
|
||||
["premium_interactions"] = new()
|
||||
{
|
||||
EntitlementRequests = 50,
|
||||
UsedRequests = 12,
|
||||
RemainingPercentage = 76,
|
||||
Overage = 0,
|
||||
OverageAllowedWithExhaustedQuota = true,
|
||||
ResetDate = "2026-04-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
IReadOnlyList<JsonElement> 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<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; }
|
||||
= new Dictionary<string, QuotaSnapshotDto>(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<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(QuotaSnapshots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ import Base from '../layouts/Base.astro';
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Live Visibility</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user