control panel updates, vm updates
This commit is contained in:
@@ -516,7 +516,10 @@ private struct DownloadBar: View {
|
||||
/// color-coded by load — green below 70%, amber 70–89%, 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" : ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (0…100, over its OWN `cores`) into a share of the
|
||||
/// whole machine (0…100, 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (0…100, 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 (0…100) — `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 0…100 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 0…100.
|
||||
/// 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 } }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, 0–100.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user