control panel updates, vm updates

This commit is contained in:
2026-07-09 02:03:53 -07:00
parent 95fb93ff59
commit 43a3045a56
31 changed files with 940 additions and 186 deletions
+4 -1
View File
@@ -516,7 +516,10 @@ private struct DownloadBar: View {
/// color-coded by load green below 70%, amber 7089%, red 90%+ so a container creeping toward
/// its memory ceiling (the status-137 OOM threshold) reads at a glance. `detail` (e.g. used/total
/// GB) rides the tooltip so both lines stay the same shape.
private struct ResourceMeter: View {
///
/// Shared with the sidebar Usage panel (`SidebarUsagePanel`), which renders the same CPU/RAM meters
/// per container/VM and for the combined total hence module-internal rather than file-private.
struct ResourceMeter: View {
@Environment(\.appPalette) private var palette
let label: String
let percent: Double
+17 -3
View File
@@ -1,6 +1,7 @@
import SwiftUI
import Foundation
import NucleicCore
import NucleicProtocol
/// The home dashboard shown when no chat is selected: a greeting, rollup stats,
/// and a GitHub-style activity grid of usage over the last several weeks.
@@ -100,9 +101,7 @@ struct HomeView: View {
: AnyLayout(HStackLayout(alignment: .top, spacing: 28))
return layout {
activityColumn(frozenDays: frozenDays)
// Only mirror the activity height side-by-side; stacked, the card sizes
// to its own content at full width.
QuotaCard(matchHeight: stacked ? 0 : activityHeight)
usageColumn(stacked: stacked)
.frame(maxWidth: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -113,6 +112,21 @@ struct HomeView: View {
})
}
/// The usage column beside the activity chart: the Claude quota card, plus the Codex card
/// stacked beneath once a Codex chat has reported usage (hidden for Claude-only users so the
/// dashboard stays uncluttered). The height-match to the activity chart applies only to a
/// lone Claude card shown side-by-side; when the Codex card joins, both size to their content.
@ViewBuilder
private func usageColumn(stacked: Bool) -> some View {
let hasCodex = store.codexUsage != nil
VStack(spacing: 16) {
QuotaCard(matchHeight: (stacked || hasCodex) ? 0 : activityHeight)
if hasCodex {
CodexUsageCard()
}
}
}
private func activityColumn(frozenDays: Set<Date>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity").font(.headline)
+123 -61
View File
@@ -1,5 +1,6 @@
import SwiftUI
import NucleicCore
import NucleicProtocol
// MARK: - Context-window usage
@@ -42,7 +43,11 @@ private enum QuotaFormat {
}
static func resetCaption(_ window: UsageWindow, now: Date) -> String? {
guard let resetsAt = window.resetsAt else { return nil }
resetCaption(window.resetsAt, now: now)
}
static func resetCaption(_ resetsAt: Date?, now: Date) -> String? {
guard let resetsAt else { return nil }
let remaining = resetsAt.timeIntervalSince(now)
guard remaining > 0 else { return "resetting…" }
return "resets in \(duration(remaining))"
@@ -81,6 +86,65 @@ private enum QuotaFormat {
default: return .secondary
}
}
/// Bar/label tint for a dashboard usage card. Uses the softer, appearance-aware `usageBar`
/// (lighter in dark, lower-contrast in light than the saturated accent) while a window sits
/// in normal range, then escalates to the shared amber/red once it runs low / over so the
/// warning still reads loudly. Shared by the Claude and Codex cards so they read identically.
static func barColor(_ utilization: Double, palette: AppPalette) -> Color {
switch utilization {
case ..<75: return AppTheme.usageBar
case ..<90: return palette.attention
default: return palette.danger
}
}
}
// MARK: - Shared card pieces
/// The thin capacity meter under a usage window's label one shape shared by every quota card.
private struct UsageBar: View {
let fraction: Double
let color: Color
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
/// One labeled window row icon, name, percentage, meter, and an optional reset countdown.
/// Data-driven (plain values, not a provider-specific window type) so the Claude and Codex
/// cards render their windows through the exact same row.
private struct UsageWindowRow: View {
let label: String
let icon: String
let utilization: Double
let resetCaption: String?
let palette: AppPalette
var body: some View {
let color = QuotaFormat.barColor(utilization, palette: palette)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(label).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
UsageBar(fraction: utilization / 100, color: color)
if let resetCaption {
Text(resetCaption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
// MARK: - Compact pill
@@ -255,86 +319,84 @@ struct QuotaCard: View {
}
}
/// One window's bar; renders nothing for an absent window.
/// One window's row; renders nothing for an absent window.
@ViewBuilder
private func windowRow(_ label: String, icon: String, _ window: UsageWindow?, now: Date) -> some View {
if let window {
let utilization = window.utilization(at: now)
let color = barColor(utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(label).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
UsageWindowRow(
label: label, icon: icon, utilization: window.utilization(at: now),
resetCaption: QuotaFormat.resetCaption(window, now: now), palette: palette)
}
}
/// Bar/label tint for the card. Uses the softer, appearance-aware `usageBar`
/// (lighter in dark, lower-contrast in light than the saturated accent) while a
/// window sits in normal range, then escalates to the shared amber/red once it
/// runs low / over so the warning still reads loudly.
private func barColor(_ utilization: Double) -> Color {
switch utilization {
case ..<75: return AppTheme.usageBar
case ..<90: return palette.attention
default: return palette.danger
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
// MARK: - Codex usage card (placeholder)
// MARK: - Codex usage card
/// A placeholder counterpart to `QuotaCard` for Codex (GPT) subscription limits. Codex has
/// no usage endpoint wired up yet, so this card only reserves the shape: the same card chrome
/// and 5-hour / weekly rows as the Claude card, dimmed, with a "Coming soon" note. When the
/// Codex usage backend lands it can render live windows here exactly like `QuotaCard`.
/// The Codex (ChatGPT-subscription) counterpart to `QuotaCard`: the same card chrome and
/// labeled window bars, reading `AppStore.codexUsage` the rolling limits the `codex`
/// app-server pushes during a turn (`account/rateLimits/updated`). Codex reports two windows
/// (`primary` 5-hour, `secondary` weekly); each renders through the shared `UsageWindowRow`
/// so it reads identically to the Claude card. Until a Codex turn has reported usage there's
/// nothing to poll, so it falls back to a dimmed placeholder with a "no data yet" note.
struct CodexUsageCard: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
/// When > 0, stretch to at least this height (set to the paired card's height so a row of
/// cards lines up mirrors `QuotaCard.matchHeight`).
var matchHeight: CGFloat = 0
var title: String = "Codex"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Codex").font(.headline)
Spacer()
Text("Coming soon")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(AppTheme.hairline, in: .capsule)
// Re-evaluate every 30s so the "resets in" countdown stays current, matching `QuotaCard`.
TimelineView(.periodic(from: .now, by: 30)) { context in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text(title).font(.headline)
}
content(now: context.date)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(16)
.frame(minHeight: matchHeight > 0 ? matchHeight : nil, alignment: .top)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
}
@ViewBuilder
private func content(now: Date) -> some View {
if let usage = store.codexUsage, usage.hasData {
VStack(alignment: .leading, spacing: 14) {
windowRow(usage.primary, fallbackLabel: "5-hour limit", icon: QuotaFormat.fiveHourIcon, now: now)
windowRow(usage.secondary, fallbackLabel: "Weekly limit", icon: QuotaFormat.weeklyIcon, now: now)
}
} else {
VStack(alignment: .leading, spacing: 14) {
placeholderRow("5-hour limit", icon: QuotaFormat.fiveHourIcon)
placeholderRow("Weekly limit", icon: QuotaFormat.weeklyIcon)
}
.opacity(0.5)
Text("Codex usage limits will appear here once support lands.")
Text("Codex usage appears once a Codex chat reports it.")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(16)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
/// A dimmed, dataless mirror of `QuotaCard.windowRow`: the label and an empty bar, with the
/// One live Codex window; renders nothing for an absent window. Labels itself from the
/// window's reported length when codex sends one, else the positional fallback.
@ViewBuilder
private func windowRow(
_ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date
) -> some View {
if let window {
UsageWindowRow(
label: window.label ?? fallbackLabel, icon: icon,
utilization: window.utilization(at: now),
resetCaption: QuotaFormat.resetCaption(window.resetsAt, now: now), palette: palette)
}
}
/// A dimmed, dataless mirror of `UsageWindowRow`: the label and an empty bar, with the
/// percentage shown as "" so the row's shape reads without implying a real value.
private func placeholderRow(_ label: String, icon: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
+12 -81
View File
@@ -6,13 +6,13 @@ import NucleicCore
struct RootView: View {
@Environment(AppStore.self) private var store
@State private var showingAddProject = false
/// Which of the three things the sidebar's top region shows recent chats (default),
/// Which of the three things the sidebar's top region shows resource usage (default),
/// the Apple Intelligence queue, or the file-lock queue. Driven by `SidebarModeSwitcher`.
@State private var sidebarMode: SidebarMode = .recents
/// Measured height of one recent-session row, sampled from a hidden row (see
/// `cardHeightSampler`). The swappable top region (recents / queue panels) is capped at
@State private var sidebarMode: SidebarMode = .usage
/// Measured height of one session row, sampled from a hidden row (see
/// `cardHeightSampler`). The swappable top region (usage / queue panels) is capped at
/// eight of these so it never crowds out the project tree below. Seeded with a sensible
/// default for the brief moment before the sample lands (or when there are no recents).
/// default for the brief moment before the sample lands (or when there are no sessions).
@State private var sessionCardHeight: CGFloat = 44
/// Polls the on-device Apple Foundation Model queue so the mode switcher can show, at a
/// glance, whether the model is busy and how much soft-AI work is waiting behind it.
@@ -334,34 +334,16 @@ struct RootView: View {
@ViewBuilder
private var topModeSection: some View {
switch sidebarMode {
case .recents:
// Lead with a status header, the way the AI and Control panels open with
// their own one-line summary (running/idle, lock counts) and a divider. Here
// it's a glanceable rollup of the recent chats how many there are, how many
// are working, how many are waiting on you so the space above the list
// carries information instead of sitting blank.
let recents = store.recentSummaries()
recentsHeader(recents)
// The queue panes carry the divider's lower gap inside their single row; the
// Recents header is its own list row, so its divider reads tighter against the
// first session below. Add the matching gap explicitly.
.padding(.bottom, 8)
case .usage:
// The live resource-usage rollup that replaced Recents: every container/VM Nucleic is
// running, each with its own CPU/RAM meter, over a combined "share of this Mac" total.
// Renders `embedded` as a single list row like the other queue panes so it scrolls with
// the list (recent chats it displaced are still listed per-project in the tree below).
SidebarUsagePanel(bodyHeight: sectionMaxHeight)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// "Recents" pins the most-recently-active chats across every project, each
// tagged with its project name (see `recentsRow`), so the user lands back on
// what they were doing without hunting per-project.
// Key these rows by a Recents-specific identity, NOT the raw `SessionID`.
// A recent session also appears under its own project further down this
// same `List`, so sharing `id` across both rows is a duplicate-ID bug:
// SwiftUI reuses one row's content for the other, which is why the
// project-name subtext would drop out of Recents or bleed onto the
// in-project row. The distinct id keeps the two rows independent.
ForEach(recents, id: \.recentsRowID) { summary in
recentsRow(summary)
.transition(.move(edge: .top).combined(with: .opacity))
}
.transition(.move(edge: .top).combined(with: .opacity))
case .intelligence:
SidebarIntelligencePanel(bodyHeight: sectionMaxHeight)
.listRowInsets(EdgeInsets())
@@ -894,48 +876,6 @@ struct RootView: View {
}
}
/// The Recents mode's status header the analogue of the AI / Control panels'
/// summary line: a clock glyph, a terse rollup of the recent chats, and a divider
/// so the header reads apart from the rows beneath it.
@ViewBuilder
private func recentsHeader(_ recents: [SessionSummary]) -> some View {
// Same header chrome as the AI / Control panes (see `SidebarPane`); Recents has no
// trailing control, and its rows flow into the list below rather than into a scroll.
SidebarPaneHeader(icon: "clock", summary: recentsSummary(recents))
}
/// Terse, glanceable summary of the recent chats the count of chats active in the
/// last 24h, plus the two states worth surfacing up front from the listed rows: how
/// many are actively working and how many are waiting on the user. Mirrors the AI
/// ("2 running · 1 waiting") and lock summaries.
private func recentsSummary(_ recents: [SessionSummary]) -> String {
guard !recents.isEmpty else { return "No recent chats" }
let working = recents.filter { $0.status == .running || $0.status == .provisioning }.count
let needsYou = recents.filter {
$0.status == .awaitingApproval
|| ($0.status == .awaitingInput && $0.disposition == .awaitingInput)
}.count
// The count is every unarchived chat touched in the last 24h not the (eight-row)
// listed set so it reflects the day's real activity, not just what's on screen.
var parts = ["\(store.recentCount()) recent"]
if working > 0 { parts.append("\(working) working") }
if needsYou > 0 { parts.append("\(needsYou) need\(needsYou == 1 ? "s" : "") you") }
return parts.joined(separator: " · ")
}
/// A Recents-section row: the same `SessionRow` used per-project, but tagged with
/// its project name as subtext and carrying its own tap/context/swipe handlers.
/// Resolves through `projectSummary` so a recent chat on a peer Mac carries its project
/// name (and control accent) exactly like a local one.
@ViewBuilder
private func recentsRow(_ summary: SessionSummary) -> some View {
let project = store.projectSummary(summary.projectID)
sessionRow(summary, projectName: project?.name)
// Tag the row with its own project's accent so a recent chat from a non-control
// project stays teal even when the open project is a Control one.
.environment(\.appPalette, project.map(palette(for:)) ?? palette)
}
@ViewBuilder
private func sessionRow(_ summary: SessionSummary, projectName: String? = nil) -> some View {
// A moved-away tombstone (mesh P5) resolves its destination name live from the paired
@@ -1054,15 +994,6 @@ struct RootView: View {
}
}
private extension SessionSummary {
/// List-row identity for the cross-project "Recents" section. The same session
/// also has a row under its own project in the *same* `List`; keying both by the
/// raw `SessionID` is a duplicate-ID bug (SwiftUI merges/reuses the rows). The
/// "recents-" prefix keeps the Recents row distinct so its project-name subtext
/// stays put and never leaks onto the in-project row.
var recentsRowID: String { "recents-\(id.rawValue)" }
}
struct SessionRow: View {
@Environment(\.appPalette) private var palette
let summary: SessionSummary
+4 -4
View File
@@ -1910,10 +1910,10 @@ private struct AgentsSettingsTab: View {
}
/// Usage: the subscription-limit gauges from the home dashboard, gathered into Settings so
/// they're checkable without leaving the panel. Claude is live the same `QuotaCard` the home
/// view shows, reading `AppStore.subscriptionUsage`. Codex is a placeholder (`CodexUsageCard`)
/// until its usage backend is wired up. Not a `Form`: the pane shows the dashboard cards
/// verbatim so the two views stay identical.
/// they're checkable without leaving the panel. Both are the same cards the home view shows:
/// `QuotaCard` reads `AppStore.subscriptionUsage` (Claude) and `CodexUsageCard` reads
/// `AppStore.codexUsage` (Codex), each falling back to its own placeholder before data lands.
/// Not a `Form`: the pane shows the dashboard cards verbatim so the two views stay identical.
private struct UsageSettingsTab: View {
var body: some View {
ScrollView {
+14 -12
View File
@@ -2,15 +2,17 @@ import SwiftUI
import AppKit
import NucleicCore
/// The three things the top region of the sidebar can show. "Recents" is the default
/// (recent chats across every project, with the project list beneath it); the other two
/// pull the cross-session Apple Intelligence queue and the Nucleic Control panel previously
/// a floating panel and a sheet inline so they're a flick of the switcher away. "Control"
/// gathers the whole Nucleic Control subsystem (shared container, autoship, file locks, and the
/// git-interceptor activity feed); its `atom` glyph matches the one Control projects carry in
/// the project tree.
/// The three things the top region of the sidebar can show. "Usage" is the default a live rollup
/// of every container/VM Nucleic is running and the share of the machine they consume; the other two
/// pull the cross-session Apple Intelligence queue and the Nucleic Control panel previously a
/// floating panel and a sheet inline so they're a flick of the switcher away. "Control" gathers the
/// whole Nucleic Control subsystem (shared container, autoship, file locks, and the git-interceptor
/// activity feed); its `atom` glyph matches the one Control projects carry in the project tree.
///
/// "Usage" replaced the old "Recents" tab: recent chats are already listed per-project in the tree
/// below, whereas nothing else surfaced how much of the Mac the sandbox fleet is using.
enum SidebarMode: String, CaseIterable, Identifiable {
case recents
case usage
case intelligence
case control
@@ -18,7 +20,7 @@ enum SidebarMode: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .recents: "Recents"
case .usage: "Usage"
case .intelligence: "Queue"
case .control: "Control"
}
@@ -26,7 +28,7 @@ enum SidebarMode: String, CaseIterable, Identifiable {
var icon: String {
switch self {
case .recents: "clock"
case .usage: "gauge.medium"
case .intelligence: "sparkles"
case .control: "atom"
}
@@ -149,8 +151,8 @@ struct SidebarModeSwitcher: View {
private func helpText(_ m: SidebarMode) -> String {
switch m {
case .recents:
return "Recent chats across every project"
case .usage:
return "Resource usage across all Nucleic containers and VMs"
case .intelligence:
if afmFrozen {
let held = afmWaiting > 0 ? "\(afmWaiting) held" : ""
+5 -9
View File
@@ -1,18 +1,14 @@
import SwiftUI
// Shared design pattern for the three swappable sidebar panes Recents, Intelligence (AI),
// Shared design pattern for the three swappable sidebar panes Usage, Intelligence (AI),
// and Control so they read and render identically. Each pane opens with a
// one-line summary header (an accent glyph, a terse status line, and an optional trailing
// control) over a divider, then a body of cards.
//
// Recents flows its session rows directly into the sidebar List's own scroll (so they keep
// their swipe actions and selection) and is hard-capped at eight rows in the data layer
// (`recentSummaries(limit:)`); it never scrolls on its own, since every session is also listed
// in the project tree just below. It therefore uses only `SidebarPaneHeader`.
//
// The queue panes are each a single List row built from `SidebarPane`: a header over a body
// that shrinks to fit its content and scrolls within an eight-card cap when there's more (their
// contents aren't shown anywhere else). The cap is eight session-card heights, matching Recents.
// All three are each a single List row built from `SidebarPane`: a header over a body that
// shrinks to fit its content and scrolls within an eight-card cap when there's more (their
// contents aren't shown anywhere else). The standalone `SidebarPaneHeader` is still exposed for
// any pane that wants only the header chrome.
/// Carries the body's natural (unclipped) height up so `SidebarPane` can size to it.
private struct SidebarPaneBodyHeightKey: PreferenceKey {
+3
View File
@@ -407,6 +407,9 @@ struct TranscriptRow: View, Equatable {
label("chart.bar", usageText(usage), .secondary)
case .rateLimit(let rl):
label("hourglass", "Rate limit: \(rl.rateLimitType ?? "?") \(rl.status ?? "")", .secondary)
case .codexUsage(let usage):
let peak = usage.peakUtilization.map { "\(Int($0.rounded()))%" } ?? ""
label("gauge.with.dots.needle.67percent", "Codex usage: \(peak)", .secondary)
case .turnCompleted:
label("circle.dotted", "Turn completed", .secondary)
case .runFinished(let finished):
+173
View File
@@ -0,0 +1,173 @@
import SwiftUI
import NucleicCore
/// The sidebar "Usage" mode (see `SidebarMode`) the cross-session view of every virtualized
/// resource Nucleic is running: the shared control container(s), any per-session sandbox
/// containers, and the per-session guest VMs (macOS and Linux). It opens with a **combined total**
/// the share of the whole machine's CPU (and summed RAM) used by *all* Nucleic virts at once
/// then lists each container and VM with its own live CPU/RAM meter.
///
/// It replaces the old "Recents" tab: recent chats are already listed per-project in the tree
/// below, whereas nothing else surfaces how much of the Mac the sandbox fleet is consuming. Like
/// the Control panel it polls `AppStore.usageSnapshot()` while shown (the resource probes aren't
/// observable) and the loop cancels on a mode switch.
struct SidebarUsagePanel: View {
/// Cap for the card list eight session cards, matching the Recents section it replaces. The
/// pane shrinks to fit fewer cards and scrolls within this when there are more (see `SidebarPane`).
let bodyHeight: CGFloat
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@State private var snapshot: ResourceUsageSnapshot?
var body: some View {
SidebarPane(icon: SidebarMode.usage.icon, summary: summary, maxBodyHeight: bodyHeight) {
EmptyView()
} content: {
content
}
.task { await poll() }
}
// Poll the usage snapshot while the pane is open. Cancels automatically on dismiss. Written only
// on change so a quiet pane doesn't re-render its whole card list every tick.
private func poll() async {
while !Task.isCancelled {
let fresh = await store.usageSnapshot()
if snapshot != fresh { snapshot = fresh }
try? await Task.sleep(for: .seconds(2))
}
}
// MARK: - Header
/// One-line rollup for the pane header: the combined machine-CPU share plus how many containers
/// and VMs are up.
private var summary: String {
guard let snapshot else { return "Loading…" }
guard !snapshot.entries.isEmpty else { return "No active containers or VMs" }
var parts = ["\(Int(snapshot.totalMachineCPUPercent.rounded()))% of CPU"]
let c = snapshot.containers.count
let v = snapshot.vms.count
if c > 0 { parts.append("\(c) container\(c == 1 ? "" : "s")") }
if v > 0 { parts.append("\(v) VM\(v == 1 ? "" : "s")") }
return parts.joined(separator: " · ")
}
// MARK: - Body
@ViewBuilder
private var content: some View {
if let snapshot {
VStack(alignment: .leading, spacing: 16) {
if snapshot.entries.isEmpty {
emptyNote
} else {
totalSection(snapshot)
if !snapshot.containers.isEmpty {
section("Containers") {
ForEach(snapshot.containers) { UsageRow(entry: $0) }
}
}
if !snapshot.vms.isEmpty {
section("Virtual Machines") {
ForEach(snapshot.vms) { UsageRow(entry: $0) }
}
}
}
}
// No top inset the header provides the standard gap below the divider.
.padding(.bottom, 12).padding(.horizontal, 8)
} else {
SidebarPanePlaceholder { ProgressView() }
}
}
private var emptyNote: some View {
Text("No containers or VMs are running. Start a Nucleic Control session or a VM to see its "
+ "resource usage here.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
}
// MARK: - Total
/// The headline combined reading: the share of the whole machine's CPU used by every Nucleic
/// virt at once, over the summed RAM of them all, with the host core count as context.
@ViewBuilder
private func totalSection(_ snapshot: ResourceUsageSnapshot) -> some View {
section("Total (all Nucleic virts)") {
VStack(spacing: 4) {
ResourceMeter(label: "CPU", percent: snapshot.totalMachineCPUPercent,
detail: "\(snapshot.hostCores) host cores")
ResourceMeter(
label: "RAM", percent: memoryPercent(snapshot.totalMemoryUsedBytes,
snapshot.totalMemoryTotalBytes),
detail: Self.memoryDetail(snapshot.totalMemoryUsedBytes,
snapshot.totalMemoryTotalBytes))
}
.padding(.vertical, 4)
}
}
private func memoryPercent(_ used: UInt64, _ total: UInt64) -> Double {
total == 0 ? 0 : min(100, Double(used) / Double(total) * 100)
}
// MARK: - Shared chrome
@ViewBuilder
private func section(_ title: String, @ViewBuilder _ body: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
body()
}
}
/// "4.2 / 8.0 GB" used vs. total for the RAM meter's tooltip.
static func memoryDetail(_ used: UInt64, _ total: UInt64) -> String {
func gb(_ bytes: UInt64) -> String { String(format: "%.1f", Double(bytes) / 1_073_741_824) }
return "\(gb(used)) / \(gb(total)) GB"
}
}
/// One container/VM row in the Usage panel: an identity line (kind glyph, friendly title, opaque
/// runtime name as subtext) with the entity's share of the whole machine's CPU trailing, over its
/// own live CPU/RAM meters. A booting VM shows "Booting" and its meters read zero until the first
/// probe lands.
private struct UsageRow: View {
@Environment(\.appPalette) private var palette
let entry: ResourceUsageEntry
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: entry.kind.symbol)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 12)
VStack(alignment: .leading, spacing: 1) {
Text(entry.title).font(.callout).lineLimit(1).truncationMode(.middle)
Text(entry.booting ? "Booting…" : entry.name)
.font(.caption2).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
Spacer(minLength: 8)
// The entity's slice of the whole machine the figure that sums into the total.
Text("\(Int(entry.machineCPUPercent.rounded()))%")
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
.help("Share of the whole machine's CPU used by this "
+ (entry.kind == .container ? "container" : "VM"))
}
VStack(spacing: 4) {
ResourceMeter(label: "CPU", percent: entry.cpuPercent)
ResourceMeter(label: "RAM", percent: entry.memoryPercent,
detail: SidebarUsagePanel.memoryDetail(entry.memoryUsedBytes,
entry.memoryTotalBytes))
}
}
.help(entry.name)
}
}
+112 -1
View File
@@ -783,6 +783,13 @@ public final class AppStore: ConflictArbiter {
/// nil until the first successful fetch (and stays at the last good value if a
/// later fetch fails, so a transient error doesn't blank the indicator).
public private(set) var subscriptionUsage: SubscriptionUsage?
/// Codex (ChatGPT-subscription) usage the two rolling rate-limit windows the `codex`
/// app-server pushes during a turn (`account/rateLimits/updated`). The Codex counterpart to
/// `subscriptionUsage`; unlike Claude's polled endpoint there's no fetch to poll, so this is
/// the newest snapshot any running Codex agent has reported (nil until the first one lands,
/// then held at the last good value). Drives the Codex quota card on the dashboard/settings.
public private(set) var codexUsage: CodexUsage?
private var quotaPollTask: Task<Void, Never>?
/// When the last usage fetch was attempted, to coalesce bursts (every session's
/// turn end triggers a refresh without this, parallel sessions stampede the
@@ -1091,6 +1098,13 @@ public final class AppStore: ConflictArbiter {
private var macVMUsageSamples: [String: MacVMResourceSample] = [:]
private var macVMUsageProbes: Set<String> = []
/// Last-known per-container usage for the Usage panel, refreshed off the snapshot's critical path
/// exactly like the meters above. Unlike `controlUsageSample` (a single aggregate reading for the
/// Control panel's one container row), this samples *every* live container so the Usage panel can
/// list each with its own load. `containerUsageProbe` is the single-flight guard.
private var containerUsageSamples: [ContainerEngine.ContainerUsageProbe] = []
private var containerUsageProbe: Task<Void, Never>?
/// The shared sandbox container's in-flight first-run download (kernel / runtime / image pull +
/// unpack), or `nil` when nothing is downloading the steady state once everything is cached.
/// Observed by the Control panel (which renders a progress bar) and by a busy chat (which names
@@ -4128,6 +4142,96 @@ public final class AppStore: ConflictArbiter {
}
}
/// Aggregate live resource usage for the sidebar "Usage" panel every container and guest VM
/// Nucleic is running right now, each with a CPU/RAM reading and its share of the whole machine's
/// CPU, plus the host core count those shares are computed against. The combined total (derived on
/// the snapshot) is "% of this Mac's CPU used by all Nucleic virts." Polled by the panel; like
/// `controlSnapshot` it never awaits the (slow) probes inline they refresh in the background and
/// it serves the last cached readings, so a stalled guest degrades a meter, not the panel.
public func usageSnapshot() async -> ResourceUsageSnapshot {
let hostCores = max(1, ProcessInfo.processInfo.processorCount)
var entries: [ResourceUsageEntry] = []
// Containers the shared control container(s) plus any per-session sandbox containers.
if ContainerServiceSettings.serviceEnabled, let containerManager {
// Friendly type labels for the known control containers (from the same source the Control
// panel's rows use); anything else live is a per-session sandbox container.
let controlLabels = Dictionary(
(await containerManager.controlContainers()).map { ($0.name, $0.typeLabel) },
uniquingKeysWith: { first, _ in first })
refreshContainerUsagesInBackground()
for probe in containerUsageSamples {
let sample = probe.sample
entries.append(ResourceUsageEntry(
kind: .container,
title: controlLabels[probe.name] ?? "Sandbox container",
name: probe.name, booting: false,
cpuPercent: sample?.cpuPercent ?? 0,
memoryUsedBytes: sample?.memoryUsedBytes ?? 0,
memoryTotalBytes: sample?.memoryTotalBytes ?? 0,
machineCPUPercent: Self.machineCPUShare(
cpuPercent: sample?.cpuPercent ?? 0, cores: probe.cpus, hostCores: hostCores)))
}
} else {
containerUsageSamples = []
}
// Guest VMs one row per running macOS/Linux guest, titled by its session's chat when we can
// map it back (mapping covers all sessions, since a VM is per-session).
if macVMSupported, let macVMManager {
var titleByVMName: [String: String] = [:]
for (_, controller) in controllers {
let session = await controller.snapshot.session
titleByVMName[MacVMManager.vmName(for: session.id)] = session.title
}
let running = await macVMManager.runningVMs()
let liveNames = Set(running.map(\.name))
macVMUsageSamples = macVMUsageSamples.filter { liveNames.contains($0.key) }
macVMUsageProbes = macVMUsageProbes.filter { liveNames.contains($0) }
for vm in running.sorted(by: { $0.name < $1.name }) {
refreshMacVMUsageInBackground(name: vm.name)
let sample = macVMUsageSamples[vm.name]
let kind: ResourceUsageEntry.Kind = vm.os == .linux ? .linuxVM : .macVM
let fallback = vm.os == .linux ? "Linux VM" : "macOS VM"
entries.append(ResourceUsageEntry(
kind: kind, title: titleByVMName[vm.name] ?? fallback, name: vm.name,
booting: vm.ipAddress == nil,
cpuPercent: sample?.cpuPercent ?? 0,
memoryUsedBytes: sample?.memoryUsedBytes ?? 0,
memoryTotalBytes: sample?.memoryTotalBytes ?? 0,
machineCPUPercent: Self.machineCPUShare(
cpuPercent: sample?.cpuPercent ?? 0, cores: vm.cpus, hostCores: hostCores)))
}
} else {
macVMUsageSamples = [:]
macVMUsageProbes = []
}
return ResourceUsageSnapshot(hostCores: hostCores, entries: entries)
}
/// Re-scale an entity's local CPU utilization (0100, over its OWN `cores`) into a share of the
/// whole machine (0100, over `hostCores`): a container pinning all 4 of its cores on an 8-core
/// Mac is at 100% locally but 50% of the machine. Clamped so a noisy probe can't overshoot.
private static func machineCPUShare(cpuPercent: Double, cores: Int, hostCores: Int) -> Double {
guard hostCores > 0 else { return 0 }
let coresUsed = max(0, cpuPercent) / 100 * Double(max(1, cores))
return min(100, coresUsed / Double(hostCores) * 100)
}
/// Refresh the per-container usage cache off the snapshot's critical path. Single-flight: a probe
/// already running is left to finish, so a slow container `statistics()` can't pile up a new probe
/// on every 2 s poll. Mirrors `refreshControlUsageInBackground`, but samples every live container.
private func refreshContainerUsagesInBackground() {
guard containerUsageProbe == nil, let containerManager else { return }
containerUsageProbe = Task { [weak self] in
let samples = await containerManager.containerUsages()
guard let self else { return }
self.containerUsageSamples = samples
self.containerUsageProbe = nil
}
}
/// Refresh `containerDownload` from the engine. Cheap a single actor hop reading an in-memory
/// value, with no I/O so it's safe to poll on a short cadence (the Control panel while it's
/// open, and a busy chat while its turn waits on the container). Clears the value when the
@@ -5275,6 +5379,13 @@ public final class AppStore: ConflictArbiter {
// The account-wide rate-limit window the CLI reports out of band: mirror the
// latest one as the coarse fallback for the global quota indicator.
if case .rateLimit(let rl) = event.kind { latestRateLimit = rl }
// Codex pushes its subscription windows the same out-of-band way (there's no endpoint
// to poll): keep the freshest snapshot for the Codex quota card, and re-push the
// dashboard to paired phones only when it actually changed.
if case .codexUsage(let usage) = event.kind, usage != codexUsage {
codexUsage = usage
broadcast(.dashboard(await dashboardSnapshot()))
}
// A finished turn just consumed quota; refresh the precise subscription usage
// so the indicator reflects it promptly rather than waiting for the next poll.
if case .runFinished = event.kind, quotaPollTask != nil {
@@ -7539,7 +7650,7 @@ extension AppStore: SyncHostBridge {
}
return DashboardSnapshot(
counts: counts, activity: activity, projects: projects, todos: todos,
usage: wireUsage(), statusFeeds: wireStatusFeeds())
usage: wireUsage(), codexUsage: codexUsage, statusFeeds: wireStatusFeeds())
}
/// The account quota windows for the phone's gauges the same `subscriptionUsage`
@@ -62,8 +62,15 @@ public final class CodexAppServerDecoder {
// Advisory streaming/structural notifications; authoritative content lands on
// `item/completed`, so these are intentional no-ops.
return []
case "account/rateLimits/updated":
// The account's rolling subscription limits (5-hour + weekly). Carried as `.codexUsage`
// telemetry hidden from the transcript, but folded into the dashboard/settings quota
// cards via `AppStore.codexUsage`. Drop if the snapshot is empty/unparseable so it's
// never a `.raw` transcript line.
guard let usage = CodexUsage.fromRateLimitsNotification(params, now: Date()) else { return [] }
return [Decoded(nativeType: method, kind: .codexUsage(usage))]
case "thread/status/changed", "mcpServer/startupStatus/updated",
"account/rateLimits/updated", "remoteControl/status/changed":
"remoteControl/status/changed":
// Benign server lifecycle chatter observed in live captures (codex 0.141.0) drop
// rather than surface as `.raw` so the transcript isn't littered with infra status.
return []
@@ -737,6 +737,32 @@ public actor ContainerEngine {
memoryTotalBytes: s2.memory?.limitBytes ?? 0)
}
/// A per-container usage reading for the Usage panel: the live CPU/RAM sample plus the core
/// count the sample was normalized against, so a caller can re-scale `cpuPercent` (which is
/// relative to *this* container's cores) into a share of the whole machine.
public struct ContainerUsageProbe: Sendable, Equatable {
public let name: String
public let cpus: Int
public let sample: ContainerResourceSample?
}
/// Sample every live container at once for the Usage panel one `ContainerUsageProbe` per
/// running container (name, its configured core count, and a best-effort CPU/RAM reading). Each
/// sample carries the ~200 ms CPU-delta sleep of `sampleResourceUsage`, so callers run this off
/// their critical path (the panel refreshes it in the background and serves the last reading).
public func sampleAllUsage() async -> [ContainerUsageProbe] {
var out: [ContainerUsageProbe] = []
// Snapshot the keys first so the sequence of awaited samples can't trip over a concurrent
// start/stop mutating `live` mid-iteration.
for name in Array(live.keys) {
guard let entry = live[name] else { continue }
let cpus = max(1, entry.container.cpus)
let sample = await sampleResourceUsage(name: name)
out.append(ContainerUsageProbe(name: name, cpus: cpus, sample: sample))
}
return out.sorted { $0.name < $1.name }
}
// MARK: - Automatic memory reclamation (balloon)
/// Start the autoballoon driver if it isn't already running. Idempotent.
@@ -352,6 +352,15 @@ public actor ContainerManager {
return nil
}
/// Live CPU/RAM of **every** container Nucleic is currently running the shared control
/// container(s) and any per-session sandbox containers for the Usage panel. One probe per live
/// container (its opaque physical name, configured cores, and a best-effort sample). Best-effort
/// and off the critical path (each sample carries a short CPU-delta sleep). Naming is left opaque
/// here; the caller maps the control containers back to friendly type labels.
public func containerUsages() async -> [ContainerEngine.ContainerUsageProbe] {
await engine.sampleAllUsage()
}
/// The shared engine's in-flight artifact download (kernel / init / image pull + unpack), for
/// the Control panel's progress bar and the chat's delay hint. One engine backs every container,
/// so this is a single, cheap read (no I/O); `nil` whenever everything is cached and nothing is
@@ -87,6 +87,9 @@ public enum ConversationExport {
return "\(seq) _usage in=\(usage.inputTokens ?? 0) out=\(usage.outputTokens ?? 0)_\n\n"
case .rateLimit(let rl):
return "\(seq) _rate limit \(rl.rateLimitType ?? "?") \(rl.status ?? "")_\n\n"
case .codexUsage(let usage):
let peak = usage.peakUtilization.map { "\(Int($0.rounded()))%" } ?? "?"
return "\(seq) _codex usage peak \(peak)_\n\n"
case .turnCompleted:
return "\(seq) _— turn completed —_\n\n"
case .runFinished(let finished):
+8 -2
View File
@@ -418,9 +418,15 @@ public actor MacVMEngine {
/// Names of all macOS VMs currently running in this process.
public func list() async -> [String] { Array(live.keys) }
/// Running macOS VMs with their discovered IPs, for the settings panel.
/// Running VMs with their discovered IPs, for the settings panel and the Usage panel. Carries
/// each guest's OS and configured core count (from the last spec it booted with) so the Usage
/// panel can label macOS vs. Linux and re-scale its CPU reading against the host.
public func runningVMs() async -> [MacVMEntry] {
live.map { MacVMEntry(name: $0.key, ipAddress: $0.value.ipAddress) }
live.map { name, vm in
MacVMEntry(
name: name, ipAddress: vm.ipAddress,
cpus: lastSpec[name]?.cpus ?? MacVMSettings.defaultVMCPUs, os: vm.os)
}
}
/// Number of macOS guests currently running the manager consults this against the configured
+10 -2
View File
@@ -1,12 +1,20 @@
import Foundation
/// One running macOS guest, for the VM settings panel.
/// One running guest VM, for the VM settings panel and the Usage panel. `cpus`/`os` let the Usage
/// panel re-scale a guest's in-VM CPU reading into a share of the whole machine and tell macOS from
/// Linux guests; both default so the settings panel's existing call sites are unaffected.
public struct MacVMEntry: Sendable, Equatable {
public let name: String
public let ipAddress: String?
public init(name: String, ipAddress: String?) {
/// Configured virtual CPU count of the guest (the denominator its in-VM `cpuPercent` is over).
public let cpus: Int
/// Which guest OS this VM boots selects the Usage panel's macOS vs. Linux glyph/label.
public let os: GuestOS
public init(name: String, ipAddress: String?, cpus: Int = 1, os: GuestOS = .macOS) {
self.name = name
self.ipAddress = ipAddress
self.cpus = max(1, cpus)
self.os = os
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ extension SessionStatus {
}
case .error(let err):
return err.recoverable ? current : .error
case .usage, .rateLimit, .note:
case .usage, .rateLimit, .codexUsage, .note:
// Pure telemetry / injected log lines never change the lifecycle state.
return current
case .userText:
@@ -51,7 +51,7 @@ public enum TranscriptItem: Identifiable {
case .thinkingTokens: return true
case .event(let event):
switch event.kind {
case .usage, .rateLimit, .turnCompleted, .raw: return true
case .usage, .rateLimit, .codexUsage, .turnCompleted, .raw: return true
default: return false
}
default: return false
+97
View File
@@ -0,0 +1,97 @@
import Foundation
// Value types for the sidebar "Usage" panel the cross-session view of every virtualized
// resource Nucleic is running right now: the shared control container(s), any per-session
// sandbox containers, and the per-session guest VMs (macOS and Linux). Each carries a live
// CPU/RAM reading plus its share of the whole machine's CPU, so the panel can show a per-entity
// meter and a single combined "how much of this Mac is Nucleic using" total.
// `AppStore.usageSnapshot()` builds a `ResourceUsageSnapshot`; the view polls it.
/// One running virtualized resource under Nucleic's control, for the Usage panel. Covers a
/// container or a guest VM; `kind` selects the glyph/label and the two VM kinds read apart
/// (macOS vs. Linux). CPU/RAM come from the same best-effort probes the Control panel uses;
/// `machineCPUPercent` is the entity's share of the **whole machine** (its local `cpuPercent`
/// re-scaled by how many of the host's cores it can use), which is what sums into the total.
public struct ResourceUsageEntry: Identifiable, Sendable, Equatable {
public enum Kind: String, Sendable, Equatable {
case container
case macVM
case linuxVM
/// SF Symbol for the row's leading glyph.
public var symbol: String {
switch self {
case .container: return "shippingbox"
case .macVM: return "macwindow"
case .linuxVM: return "terminal"
}
}
}
/// Stable identity the opaque runtime name, which is unique across containers and VMs.
public var id: String { name }
public let kind: Kind
/// Friendly heading a container's type ("Shared" / "Claude"), or a VM's session title.
public let title: String
/// The opaque (randomized) runtime name, shown as subtext / tooltip.
public let name: String
/// The guest is up but not fully reachable yet (a VM still acquiring its NAT IP) the meters
/// read zero until the first real probe lands.
public let booting: Bool
/// Local CPU utilization over the sample window (0100, relative to the entity's OWN cores).
public let cpuPercent: Double
public let memoryUsedBytes: UInt64
public let memoryTotalBytes: UInt64
/// This entity's share of the whole machine's CPU (0100) `cpuPercent` re-scaled by the
/// fraction of host cores it runs on. This is what sums into the snapshot's combined total.
public let machineCPUPercent: Double
public init(
kind: Kind, title: String, name: String, booting: Bool,
cpuPercent: Double, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64,
machineCPUPercent: Double
) {
self.kind = kind
self.title = title
self.name = name
self.booting = booting
self.cpuPercent = min(100, max(0, cpuPercent))
self.memoryUsedBytes = memoryUsedBytes
self.memoryTotalBytes = memoryTotalBytes
self.machineCPUPercent = min(100, max(0, machineCPUPercent))
}
/// Memory utilization 0100 of this entity's own ceiling. Zero when the total is unknown.
public var memoryPercent: Double {
memoryTotalBytes == 0 ? 0 : min(100, Double(memoryUsedBytes) / Double(memoryTotalBytes) * 100)
}
}
/// Everything the Usage panel renders, gathered in one `@MainActor` call (`AppStore.usageSnapshot()`)
/// so the view polls a single entry point. Carries the host's logical core count (the denominator for
/// every machine-relative figure) and the per-entity entries; the combined total is derived.
public struct ResourceUsageSnapshot: Sendable, Equatable {
/// Logical core count of the host machine the denominator behind every `machineCPUPercent`.
public let hostCores: Int
/// One entry per running container / VM, containers first then VMs (each group name-sorted).
public let entries: [ResourceUsageEntry]
public init(hostCores: Int, entries: [ResourceUsageEntry]) {
self.hostCores = max(1, hostCores)
self.entries = entries
}
/// Combined share of the whole machine's CPU used by every Nucleic container/VM, clamped 0100.
/// This is the panel's headline "total usage" figure.
public var totalMachineCPUPercent: Double {
min(100, max(0, entries.reduce(0) { $0 + $1.machineCPUPercent }))
}
/// Total resident memory across every entity, and the sum of their ceilings for the combined
/// RAM readout.
public var totalMemoryUsedBytes: UInt64 { entries.reduce(0) { $0 + $1.memoryUsedBytes } }
public var totalMemoryTotalBytes: UInt64 { entries.reduce(0) { $0 + $1.memoryTotalBytes } }
public var containers: [ResourceUsageEntry] { entries.filter { $0.kind == .container } }
public var vms: [ResourceUsageEntry] { entries.filter { $0.kind != .container } }
}
+8
View File
@@ -54,6 +54,11 @@ extension AgentEvent {
case approvalResolved(ApprovalResolved)
case usage(Usage)
case rateLimit(RateLimit)
/// A Codex (ChatGPT-subscription) rate-limit snapshot pushed out of band during a turn
/// (`account/rateLimits/updated`) the two rolling windows the quota cards render. The
/// Codex analog of `rateLimit`; carried as telemetry (hidden from the transcript, folded
/// into `AppStore.codexUsage`).
case codexUsage(CodexUsage)
case turnCompleted(TurnCompleted)
case runFinished(RunFinished)
case error(AgentError)
@@ -81,6 +86,7 @@ extension AgentEvent {
case .approvalResolved: "approvalResolved"
case .usage: "usage"
case .rateLimit: "rateLimit"
case .codexUsage: "codexUsage"
case .turnCompleted: "turnCompleted"
case .runFinished: "runFinished"
case .error: "error"
@@ -442,6 +448,7 @@ extension AgentEvent.Kind: Codable {
case "approvalResolved": self = .approvalResolved(try ApprovalResolved(from: decoder))
case "usage": self = .usage(try Usage(from: decoder))
case "rateLimit": self = .rateLimit(try RateLimit(from: decoder))
case "codexUsage": self = .codexUsage(try CodexUsage(from: decoder))
case "turnCompleted": self = .turnCompleted(try TurnCompleted(from: decoder))
case "runFinished": self = .runFinished(try RunFinished(from: decoder))
case "error": self = .error(try AgentError(from: decoder))
@@ -470,6 +477,7 @@ extension AgentEvent.Kind: Codable {
case .approvalResolved(let p): try p.encode(to: encoder)
case .usage(let p): try p.encode(to: encoder)
case .rateLimit(let p): try p.encode(to: encoder)
case .codexUsage(let p): try p.encode(to: encoder)
case .turnCompleted(let p): try p.encode(to: encoder)
case .runFinished(let p): try p.encode(to: encoder)
case .error(let p): try p.encode(to: encoder)
+105
View File
@@ -0,0 +1,105 @@
import Foundation
/// One Codex (ChatGPT-subscription) rate-limit window the shape the `codex` app-server
/// pushes in its `account/rateLimits/updated` notification: a percentage consumed plus the
/// window's length and when it refreshes. Codex reports two windows a short rolling one
/// (`primary`, typically ~5 hours) and a long one (`secondary`, typically weekly) so this
/// is the Codex counterpart to Claude's `UsageWindow`.
public struct CodexUsageWindow: Sendable, Codable, Equatable {
/// Percent of the window consumed, 0100.
public let utilization: Double
/// The window's length in minutes, when reported. Used only to label the row ("5-hour"
/// vs "Weekly"); the percentage and reset are what actually render.
public let windowMinutes: Int?
/// When this window rolls over and frees capacity (nil if not reported). Computed from
/// codex's *relative* `resetsInSeconds` at the moment the snapshot was decoded, so it
/// survives the wire and the periodic re-render as an absolute instant.
public let resetsAt: Date?
public init(utilization: Double, windowMinutes: Int?, resetsAt: Date?) {
self.utilization = utilization
self.windowMinutes = windowMinutes
self.resetsAt = resetsAt
}
/// The utilization to show at `now`, correcting for a stale snapshot across a reset the
/// same coarse-poll correction `UsageWindow` makes. Codex only pushes a fresh snapshot
/// during a turn, so between turns a lapsed window would otherwise report its stale
/// pre-reset percentage; once `resetsAt` has passed it reads as empty (0%) instead.
public func utilization(at now: Date) -> Double {
if let resetsAt, now >= resetsAt { return 0 }
return utilization
}
/// A short human label derived from the window's length: sub-day windows read as "N-hour",
/// a ~week reads "Weekly", other multiples of a day read "N-day". `nil` when codex omits the
/// length, so the caller can fall back to a positional default ("5-hour" / "Weekly").
public var label: String? {
guard let windowMinutes, windowMinutes > 0 else { return nil }
if windowMinutes % (60 * 24) == 0 {
let days = windowMinutes / (60 * 24)
return days == 7 ? "Weekly" : "\(days)-day"
}
let hours = windowMinutes / 60
return hours >= 1 ? "\(hours)-hour" : "\(windowMinutes)-min"
}
}
/// Codex subscription usage the two rolling rate-limit windows the `codex` app-server
/// reports for a ChatGPT-authenticated account, the Codex counterpart to Claude's
/// `SubscriptionUsage`. Unlike Claude's (polled from an OAuth usage endpoint), these arrive
/// as a push notification (`account/rateLimits/updated`) during a turn, so the newest one
/// seen is the freshest available (`AppStore.codexUsage`). Codable so it rides both the
/// normalized `AgentEvent` stream and `DashboardSnapshot` to paired phones unchanged.
public struct CodexUsage: Sendable, Codable, Equatable {
/// The short rolling window (typically ~5 hours).
public let primary: CodexUsageWindow?
/// The long rolling window (typically weekly).
public let secondary: CodexUsageWindow?
public init(primary: CodexUsageWindow?, secondary: CodexUsageWindow?) {
self.primary = primary
self.secondary = secondary
}
/// The most-constrained window's utilization the single headline number, since hitting
/// *either* window throttles you.
public var peakUtilization: Double? {
[primary, secondary].compactMap { $0?.utilization }.max()
}
/// True when at least one window carries a real reading lets the UI choose between the
/// live card and the "no data yet" placeholder.
public var hasData: Bool { primary != nil || secondary != nil }
}
extension CodexUsage {
/// Parse the `params` of a codex `account/rateLimits/updated` notification into a snapshot.
///
/// Deliberately lenient about field naming (camelCase from the app-server, snake_case from
/// the exec/core wire) and missing fields, since the shape is codex-internal and unversioned:
/// returns `nil` only when *no* window can be read at all, so a malformed or empty snapshot
/// never displaces a good one already on screen. `now` anchors each window's relative
/// `resetsInSeconds` to an absolute `resetsAt` (injectable for tests).
public static func fromRateLimitsNotification(_ params: JSONValue, now: Date) -> CodexUsage? {
// The two windows sit under `rateLimits` (app-server) / `rate_limits` (core), or,
// tolerantly, at the params root.
let root = params["rateLimits"] ?? params["rate_limits"] ?? params
let primary = window(root["primary"], now: now)
let secondary = window(root["secondary"], now: now)
guard primary != nil || secondary != nil else { return nil }
return CodexUsage(primary: primary, secondary: secondary)
}
/// One window object `CodexUsageWindow`; `nil` unless it carries a usable percentage.
private static func window(_ value: JSONValue?, now: Date) -> CodexUsageWindow? {
guard let value, value.objectValue != nil,
let used = (value["usedPercent"] ?? value["used_percent"])?.numberValue
else { return nil }
let minutes = (value["windowMinutes"] ?? value["window_minutes"])?.intValue
let resetsAt = (value["resetsInSeconds"] ?? value["resets_in_seconds"])?.numberValue
.map { now.addingTimeInterval($0) }
return CodexUsageWindow(
utilization: min(100, max(0, used)), windowMinutes: minutes, resetsAt: resetsAt)
}
}
+10 -2
View File
@@ -143,25 +143,32 @@ public struct DashboardSnapshot: Sendable, Codable, Equatable {
/// Account-wide subscription usage for the phone's quota gauges (nil when the host
/// can't fetch it logged out, API-key auth, or a host that predates the field).
public let usage: WireSubscriptionUsage?
/// Codex (ChatGPT-subscription) usage for the phone's Codex quota gauge the two rolling
/// windows the host's `AppStore.codexUsage` holds. `CodexUsage` is already wire-safe, so it
/// rides directly (no separate projection). Nil until a Codex turn reports one, or from a
/// host that predates the field.
public let codexUsage: CodexUsage?
/// Provider status feeds for the phone's status pill (empty before the host's first
/// poll, or from a host that predates the field).
public let statusFeeds: [WireStatusFeed]
public init(
counts: DashboardCounts, activity: [ActivityDay], projects: [WireProject],
todos: [WireTodo], usage: WireSubscriptionUsage? = nil, statusFeeds: [WireStatusFeed] = []
todos: [WireTodo], usage: WireSubscriptionUsage? = nil, codexUsage: CodexUsage? = nil,
statusFeeds: [WireStatusFeed] = []
) {
self.counts = counts
self.activity = activity
self.projects = projects
self.todos = todos
self.usage = usage
self.codexUsage = codexUsage
self.statusFeeds = statusFeeds
}
public static let empty = DashboardSnapshot(counts: .empty, activity: [], projects: [], todos: [])
private enum CodingKeys: String, CodingKey {
case counts, activity, projects, todos, usage, statusFeeds
case counts, activity, projects, todos, usage, codexUsage, statusFeeds
}
public init(from decoder: Decoder) throws {
@@ -172,6 +179,7 @@ public struct DashboardSnapshot: Sendable, Codable, Equatable {
self.todos = try c.decode([WireTodo].self, forKey: .todos)
// Tolerate dashboards from a host that predates usage/status projection.
self.usage = try c.decodeIfPresent(WireSubscriptionUsage.self, forKey: .usage)
self.codexUsage = try c.decodeIfPresent(CodexUsage.self, forKey: .codexUsage)
self.statusFeeds = try c.decodeIfPresent([WireStatusFeed].self, forKey: .statusFeeds) ?? []
}
}
+3
View File
@@ -193,6 +193,9 @@ struct Spike {
case .rateLimit(let rl):
let resets = rl.resetsAt.map { " resets=\($0)" } ?? ""
print("\(prefix) ⏳ rate-limit \(rl.rateLimitType ?? "?") status=\(rl.status ?? "?")\(resets)")
case .codexUsage(let usage):
let peak = usage.peakUtilization.map { String(format: "%.0f%%", $0) } ?? "?"
print("\(prefix) 📈 codex-usage peak=\(peak)")
case .turnCompleted:
print("\(prefix) ── turn completed")
case .runFinished(let finished):
@@ -210,6 +210,31 @@ import Testing
#expect(tc.stopReason == "completed")
}
@Test func rateLimitsNotificationBecomesCodexUsage() {
let decoder = CodexAppServerDecoder()
let out = decoder.decode(
method: "account/rateLimits/updated",
params: ["rateLimits": [
"primary": ["usedPercent": 42.5, "windowMinutes": 300, "resetsInSeconds": 3600],
"secondary": ["usedPercent": 12, "windowMinutes": 10080]]])
#expect(tags(out) == ["codexUsage"])
guard case .codexUsage(let usage) = out.first?.kind else { Issue.record("not codexUsage"); return }
#expect(usage.primary?.utilization == 42.5)
#expect(usage.primary?.windowMinutes == 300)
#expect(usage.primary?.resetsAt != nil) // computed from resetsInSeconds
#expect(usage.secondary?.utilization == 12)
#expect(usage.secondary?.resetsAt == nil) // no resetsInSeconds reported
#expect(usage.peakUtilization == 42.5)
}
@Test func emptyRateLimitsNotificationIsDropped() {
// A snapshot with no readable window is dropped rather than surfaced as a `.raw` line
// so a malformed push never displaces a good snapshot already on the card.
let decoder = CodexAppServerDecoder()
#expect(decoder.decode(method: "account/rateLimits/updated", params: ["rateLimits": [:]]).isEmpty)
#expect(decoder.decode(method: "account/rateLimits/updated", params: [:]).isEmpty)
}
@Test func unknownNotificationPassesThroughAsRaw() {
let decoder = CodexAppServerDecoder()
let out = decoder.decode(method: "thread/somethingNew", params: ["a": 1])
@@ -59,6 +59,21 @@ import Testing
#expect(back == event)
}
@Test func codexUsageEventRoundTrips() throws {
// Locks the new `.codexUsage` kind through the real CBOR wire path (tag discriminator
// + nested optional windows).
let event = AgentEvent(
sessionID: SessionID(rawValue: "s1"), seq: 7,
at: Date(timeIntervalSince1970: 1_700_000_000), backend: .codex,
nativeType: "account/rateLimits/updated",
kind: .codexUsage(CodexUsage(
primary: CodexUsageWindow(
utilization: 42.5, windowMinutes: 300,
resetsAt: Date(timeIntervalSince1970: 1_700_003_600)),
secondary: CodexUsageWindow(utilization: 12, windowMinutes: 10080, resetsAt: nil))))
#expect(try roundTrip(event) == event)
}
@Test func mapKeyOrderIsDeterministic() throws {
// Same logical object, different dict literal order identical bytes.
let a: JSONValue = ["z": 1, "a": 2, "m": 3]
@@ -0,0 +1,66 @@
import Foundation
import Testing
@testable import NucleicProtocol
/// `CodexUsage` parsing the `codex` `account/rateLimits/updated` snapshot, the stale-window
/// correction, window labels, and Codable round-tripping (it rides both `AgentEvent` and
/// `DashboardSnapshot`).
@Suite struct CodexUsageTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
@Test func parsesCamelCaseAppServerShape() throws {
let params: JSONValue = ["rateLimits": [
"primary": ["usedPercent": 42.5, "windowMinutes": 300, "resetsInSeconds": 3600],
"secondary": ["usedPercent": 12, "windowMinutes": 10080]]]
let usage = try #require(CodexUsage.fromRateLimitsNotification(params, now: now))
#expect(usage.primary?.utilization == 42.5)
#expect(usage.primary?.windowMinutes == 300)
#expect(usage.primary?.resetsAt == now.addingTimeInterval(3600))
#expect(usage.secondary?.utilization == 12)
#expect(usage.secondary?.resetsAt == nil)
#expect(usage.peakUtilization == 42.5)
#expect(usage.hasData)
}
@Test func parsesSnakeCaseCoreShape() throws {
// The exec/core wire uses snake_case and may nest under `rate_limits`.
let params: JSONValue = ["rate_limits": [
"primary": ["used_percent": 5, "window_minutes": 300, "resets_in_seconds": 60]]]
let usage = try #require(CodexUsage.fromRateLimitsNotification(params, now: now))
#expect(usage.primary?.utilization == 5)
#expect(usage.primary?.resetsAt == now.addingTimeInterval(60))
#expect(usage.secondary == nil)
}
@Test func clampsUtilizationAndDropsEmptySnapshots() {
let over: JSONValue = ["rateLimits": ["primary": ["usedPercent": 140]]]
#expect(CodexUsage.fromRateLimitsNotification(over, now: now)?.primary?.utilization == 100)
// No usable window nil, so a malformed push never displaces a good snapshot.
#expect(CodexUsage.fromRateLimitsNotification(["rateLimits": [:]], now: now) == nil)
#expect(CodexUsage.fromRateLimitsNotification(["rateLimits": ["primary": ["windowMinutes": 300]]], now: now) == nil)
}
@Test func lapsedWindowReadsAsEmpty() {
let window = CodexUsageWindow(utilization: 80, windowMinutes: 300, resetsAt: now.addingTimeInterval(60))
#expect(window.utilization(at: now) == 80) // before reset: as-is
#expect(window.utilization(at: now.addingTimeInterval(120)) == 0) // past reset: freed
// A window with no reset is always reported as-is.
let noReset = CodexUsageWindow(utilization: 30, windowMinutes: nil, resetsAt: nil)
#expect(noReset.utilization(at: now.addingTimeInterval(1_000_000)) == 30)
}
@Test func windowLabelsFromLength() {
#expect(CodexUsageWindow(utilization: 0, windowMinutes: 300, resetsAt: nil).label == "5-hour")
#expect(CodexUsageWindow(utilization: 0, windowMinutes: 10080, resetsAt: nil).label == "Weekly")
#expect(CodexUsageWindow(utilization: 0, windowMinutes: nil, resetsAt: nil).label == nil)
}
@Test func codableRoundTrips() throws {
let usage = CodexUsage(
primary: CodexUsageWindow(utilization: 42.5, windowMinutes: 300, resetsAt: now),
secondary: CodexUsageWindow(utilization: 12, windowMinutes: 10080, resetsAt: nil))
let data = try JSONEncoder().encode(usage)
#expect(try JSONDecoder().decode(CodexUsage.self, from: data) == usage)
}
}
@@ -1828,7 +1828,8 @@ final class RemoteStore: ObservableObject {
private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
dashboard = DashboardSnapshot(
counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
todos: transform(dashboard.todos), usage: dashboard.usage,
codexUsage: dashboard.codexUsage, statusFeeds: dashboard.statusFeeds)
}
@@ -1910,6 +1911,7 @@ extension DashboardSnapshot {
tokens: snaps.reduce(0) { $0 + $1.counts.tokens })
let usage = snaps.compactMap(\.usage).first
let codexUsage = snaps.compactMap(\.codexUsage).first
var seenProviders = Set<String>()
var statusFeeds: [WireStatusFeed] = []
@@ -1921,7 +1923,7 @@ extension DashboardSnapshot {
return DashboardSnapshot(
counts: counts, activity: activity, projects: projects,
todos: todos, usage: usage, statusFeeds: statusFeeds)
todos: todos, usage: usage, codexUsage: codexUsage, statusFeeds: statusFeeds)
}
}
@@ -82,8 +82,9 @@ struct HomeView: View {
statCards
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
// doesn't project usage.
// doesn't project usage. Codex's card follows, shown once a Codex chat reports.
QuotaCard(usage: store.dashboard.usage)
CodexQuotaCard(usage: store.dashboard.codexUsage)
VStack(alignment: .leading, spacing: 10) {
Text("Activity").font(.headline)
@@ -26,7 +26,11 @@ enum QuotaFormat {
}
static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? {
guard let resetsAt = window.resetsAt else { return nil }
resetCaption(window.resetsAt, now: now)
}
static func resetCaption(_ resetsAt: Date?, now: Date) -> String? {
guard let resetsAt else { return nil }
let remaining = resetsAt.timeIntervalSince(now)
guard remaining > 0 else { return "resetting…" }
return "resets in \(duration(remaining))"
@@ -101,6 +105,70 @@ struct QuotaCard: View {
}
}
// MARK: - Codex quota card (the Mac's CodexUsageCard, dashboard form)
/// The Codex counterpart to `QuotaCard`, rendered from `DashboardSnapshot.codexUsage` the
/// rolling windows the host's `codex` agent reported (`primary` 5-hour, `secondary` weekly).
/// Renders nothing until a Codex chat has reported usage, so Claude-only users never carry an
/// empty card. Same thresholds, icons, and copy as the Claude card.
struct CodexQuotaCard: View {
let usage: CodexUsage?
var body: some View {
if let usage, usage.hasData {
TimelineView(.periodic(from: .now, by: 30)) { context in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Codex").font(.headline)
}
windowRow(usage.primary, fallbackLabel: "5-hour limit",
icon: QuotaFormat.fiveHourIcon, now: context.date)
windowRow(usage.secondary, fallbackLabel: "Weekly limit",
icon: QuotaFormat.weeklyIcon, now: context.date)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.card()
}
}
@ViewBuilder
private func windowRow(
_ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date
) -> some View {
if let window {
let utilization = window.utilization(at: now)
let color = QuotaFormat.color(forPercent: utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(window.label ?? fallbackLabel).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window.resetsAt, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
// MARK: - Status pill (the Mac's StatusFeedIndicator)
/// A compact service-status pill: a calm green check while every watched provider is
@@ -263,7 +263,7 @@ final class IncrementalTranscriptProjection {
if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
case .toolResult(let result):
spans[result.toolCallID]?.resultSeen = true
case .toolCallInputDelta, .fileChange, .approvalResolved:
case .toolCallInputDelta, .fileChange, .approvalResolved, .codexUsage:
break // refs (if any) tracked above; creates nothing
case .turnCompleted, .runFinished:
creations[r] = .separator
@@ -478,6 +478,8 @@ enum TranscriptProjection {
items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
case .rateLimit(let limit):
items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
case .codexUsage:
break // account-wide quota telemetry, folded into the dashboard not the transcript
case .turnCompleted:
items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
case .approvalRequested(let req):