Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-proof/menubar-conditionals-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions .github/pr-proof/menubar-conditionals-runtime.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
CodexBar menu bar conditional tokens - runtime proof
Captured: 2026-08-19T20:12:13+01:00
Bundle: ./CodexBar.app (release, packaged via Scripts/package_app.sh)
Provider shown: Claude (usage 87%)

=== 1. Starter library seeds on the fresh-install path ===
The menuBarLayoutConditionals key was deleted before launch, then the app was started.
The five shipped rules appear in Settings > Menu Bar > Conditionals
(see menubar-conditionals-editor.png), wrapping across two rows.

menuBarLayoutConditionals: <absent>
-> Seeding happens in memory only; nothing is written at load. A library the user
empties therefore stays empty, because any edit/remove writes the key and the
stored value wins from then on.

=== 2. Downgrade projection keeps the legacy blob 0.53.x-decodable ===
Layout under test is stacked, and line 2 holds only a conditional reference:

menuBarLayoutV2 (current): {"lines":[[{"icon":{}},{"percent":{"window":"automatic"}}],[{"conditional":{"id":"B715B1D1-8C1D-4E99-8050-2B5A4EF4B684"}}]]}
menuBarLayout (legacy) : {"lines":[[{"icon":{}},{"percent":{"window":"automatic"}}]]}

-> The legacy blob carries no "conditional" token, so an older decoder reads the
layout instead of failing on an unknown case and discarding the whole blob.

=== 3. A hidden branch collapses its line ===
Session usage sits below the shipped rule's 50% threshold, so line 2 resolves to
.hidden and the whole line drops out.

status item AXTitle: Claude icon, Usage 87%

-> One line announced. No "Line 2" segment, no blank trailing line, and the title
keeps single-line typography. The editor's Live preview shows "87%" on one row
while the Menu bar strip still lists two rows (screenshot above).
16 changes: 16 additions & 0 deletions Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.installDebugMemoryPressureObserverIfNeeded()
#endif
self.ensureStatusController()
self.closeSwiftUISettingsPlaceholderWindow()
self.observeSettingsApplicationMenuLanguage()
self.scheduleSettingsApplicationMenuValidation(
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
Expand Down Expand Up @@ -497,6 +498,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

/// The SwiftUI `Settings` scene exists only to own the app-menu Settings command; the real
/// settings window is AppKit-managed (`SettingsWindowController`). macOS can still present or
/// state-restore the scene's empty placeholder window at launch — close it and keep it out of
/// state restoration so it cannot come back on the next launch.
private func closeSwiftUISettingsPlaceholderWindow() {
DispatchQueue.main.async {
for window in NSApp.windows
where window.identifier?.rawValue.hasPrefix("com_apple_SwiftUI_Settings") == true
{
window.isRestorable = false
window.close()
}
}
}

func applicationWillTerminate(_ notification: Notification) {
self.cloudSyncCoordinator?.stop()
self.memoryPressureMonitor.stop()
Expand Down
273 changes: 270 additions & 3 deletions Sources/CodexBar/MenuBarLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable {
case automatic
}

/// Deliberately mirrors `PercentWindow` but stays a separate type so predicate persistence
/// is decoupled from render-window naming.
enum MenuBarConditionalMetric: String, CaseIterable, Codable, Hashable, Sendable {
case session
case weekly
case scopedWeekly
case automatic
}

enum MenuBarConditionalComparison: String, CaseIterable, Codable, Hashable, Sendable {
case greaterThan
case greaterThanOrEqual
case lessThan
case lessThanOrEqual

var symbol: String {
switch self {
case .greaterThan: ">"
case .greaterThanOrEqual: ">="
case .lessThan: "<"
case .lessThanOrEqual: "<="
}
}

func evaluate(_ value: Double, _ threshold: Double) -> Bool {
switch self {
case .greaterThan: value > threshold
case .greaterThanOrEqual: value >= threshold
case .lessThan: value < threshold
case .lessThanOrEqual: value <= threshold
}
}
}

enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable {
case primary
case secondary
Expand All @@ -31,6 +65,164 @@ enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable {
}
}

enum MenuBarConditionalCombinator: String, CaseIterable, Codable, Hashable, Sendable {
case and
case or
}

struct MenuBarConditionalPredicate: Codable, Hashable, Sendable {
var metric: MenuBarConditionalMetric
var comparison: MenuBarConditionalComparison
var threshold: Double
}

struct MenuBarConditionalClause: Codable, Hashable, Sendable {
/// nil for the first clause; ignored-on-eval if set on the first.
var combinator: MenuBarConditionalCombinator?
var predicate: MenuBarConditionalPredicate
}

struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
let id: UUID
var name: String
var clauses: [MenuBarConditionalClause] // 1...4 after normalization
var thenToken: MenuBarLayoutToken
var elseToken: MenuBarLayoutToken

init(
id: UUID = UUID(),
name: String = "",
clauses: [MenuBarConditionalClause],
thenToken: MenuBarLayoutToken,
elseToken: MenuBarLayoutToken)
{
self.id = id
self.name = name
self.clauses = clauses
self.thenToken = thenToken
self.elseToken = elseToken
self.normalize()
}

private mutating func normalize() {
var normalized = self.clauses.prefix(4).map { clause in
var clause = clause
clause.predicate.threshold = min(max(clause.predicate.threshold, 0), 100)
return clause
}
if normalized.isEmpty {
normalized = [MenuBarConditionalClause(
combinator: nil,
predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))]
}
normalized[0].combinator = nil
self.clauses = Array(normalized)
}

/// Custom Codable so older persisted conditionals without `name` or `id` still decode.
private enum CodingKeys: String, CodingKey {
case id, name, clauses, thenToken, elseToken
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
self.clauses = try container.decode([MenuBarConditionalClause].self, forKey: .clauses)
self.thenToken = try container.decode(MenuBarLayoutToken.self, forKey: .thenToken)
self.elseToken = try container.decode(MenuBarLayoutToken.self, forKey: .elseToken)
self.normalize()
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.name, forKey: .name)
try container.encode(self.clauses, forKey: .clauses)
try container.encode(self.thenToken, forKey: .thenToken)
try container.encode(self.elseToken, forKey: .elseToken)
}

static func makeDefault() -> MenuBarLayoutConditional {
MenuBarLayoutConditional(
clauses: [MenuBarConditionalClause(
combinator: nil,
predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))],
thenToken: .percent(window: .session),
elseToken: .hidden)
}

/// Conditionals seeded into the library on a fresh install so the palette ships with useful,
/// editable starting points instead of an empty list.
///
/// Identities are fixed rather than generated: a layout that places a shipped conditional keeps
/// resolving across launches, and once the user edits or clears the library the stored array wins,
/// so a deleted entry is never reseeded.
///
/// Thresholds compare the window's **used** percentage, matching `evaluatesTrue`.
static func shippedLibrary() -> [MenuBarLayoutConditional] {
[
MenuBarLayoutConditional(
id: self.fixedID("B715B1D1-8C1D-4E99-8050-2B5A4EF4B684"),
name: L("menu_bar_layout_conditional_default_session_busy"),
clauses: [self.clause(.session, .greaterThan, 50)],
thenToken: .percent(window: .session),
elseToken: .hidden),
MenuBarLayoutConditional(
id: self.fixedID("A1C3C131-1BB8-4248-A482-FAF3E403E6E1"),
name: L("menu_bar_layout_conditional_default_weekly_high"),
clauses: [self.clause(.weekly, .greaterThanOrEqual, 90)],
thenToken: .percent(window: .weekly),
elseToken: .hidden),
MenuBarLayoutConditional(
id: self.fixedID("DBF99E1D-D5ED-4D55-AD24-A4171479DA3A"),
name: L("menu_bar_layout_conditional_default_session_spent"),
clauses: [self.clause(.session, .greaterThanOrEqual, 95)],
thenToken: .resetCountdown,
elseToken: .percent(window: .session)),
MenuBarLayoutConditional(
id: self.fixedID("CB1EBADE-B813-4B70-A32D-0FC742DC97A6"),
name: L("menu_bar_layout_conditional_default_either_high"),
clauses: [
self.clause(.session, .greaterThan, 80),
self.clause(.weekly, .greaterThan, 80, combinator: .or),
],
thenToken: .resetCountdown,
elseToken: .hidden),
MenuBarLayoutConditional(
id: self.fixedID("4A4E53F8-CABC-4413-B7AA-6C6452A69FEC"),
name: L("menu_bar_layout_conditional_default_scoped_weekly"),
clauses: [self.clause(.scopedWeekly, .greaterThan, 60)],
thenToken: .percent(window: .scopedWeekly),
elseToken: .hidden),
]
}

private static func clause(
_ metric: MenuBarConditionalMetric,
_ comparison: MenuBarConditionalComparison,
_ threshold: Double,
combinator: MenuBarConditionalCombinator? = nil)
-> MenuBarConditionalClause
{
MenuBarConditionalClause(
combinator: combinator,
predicate: MenuBarConditionalPredicate(
metric: metric,
comparison: comparison,
threshold: threshold))
}

/// The shipped identities are compile-time constants, so a malformed one is a programmer error
/// rather than something to paper over with a fresh identity that would dangle placed references.
private static func fixedID(_ string: String) -> UUID {
guard let id = UUID(uuidString: string) else {
preconditionFailure("Shipped conditional identity must be a valid UUID: \(string)")
}
return id
}
}

struct MenuBarLayoutLaneLabels: Hashable {
let primary: String
let secondary: String
Expand Down Expand Up @@ -74,12 +266,28 @@ enum MenuBarLayoutToken: Codable, Hashable, Sendable {
case cost30d
case separatorDot
case space
/// Renders nothing; used as a conditional branch value to hide output for the other case.
case hidden
/// References a library conditional by UUID. The conditional's content (clauses, branches) lives in
/// the conditionals library; the layout stores only its identity.
case conditional(id: UUID)

var selectedLane: MenuBarLayoutLane? {
if case let .lanePercent(lane) = self { return lane }
return nil
}

/// Tokens added after 0.53.x that an older decoder has no case for at all. `legacyCompatible`
/// cannot map them onto an existing case without inventing content, so the layout projection
/// drops them instead: an older release then decodes the rest of the layout rather than
/// failing the whole blob and losing the user's arrangement.
var hasLegacyRepresentation: Bool {
switch self {
case .conditional, .hidden: false
default: true
}
}

/// Maps `lanePercent` onto tokens a 0.53.x decoder already understands so a downgrade keeps a
/// layout instead of dropping the whole blob. Direct lanes follow the provider's semantic
/// windows: Kimi's primary is weekly, so a Kimi override does not swap 7-day and 5-hour.
Expand Down Expand Up @@ -183,10 +391,21 @@ struct MenuBarLayout: Codable, Hashable, Sendable {
Set(self.lines.joined().compactMap(\.selectedLane))
}

/// Older-readable projection of this layout. Tokens an older decoder cannot represent are
/// dropped rather than mapped; a line left empty by that filtering is dropped too, and a layout
/// with nothing left falls back to `defaultLayout` via `MenuBarLayout(lines:)` normalization.
func legacyCompatible(for provider: UsageProvider? = nil) -> MenuBarLayout {
MenuBarLayout(lines: self.lines.map { line in
line.map { $0.legacyCompatible(for: provider) }
})
let projected = self.lines.map { line in
line
.filter(\.hasLegacyRepresentation)
.map { $0.legacyCompatible(for: provider) }
}
// Keep a trailing empty line only when the layout was already stacked with an empty line,
// so an older release does not inherit a blank stacked row created purely by filtering.
let compacted = projected.enumerated().filter { index, line in
!line.isEmpty || self.lines[index].isEmpty
}.map(\.element)
return MenuBarLayout(lines: compacted)
}
}

Expand Down Expand Up @@ -485,3 +704,51 @@ enum MenuBarLayoutPersistence {
return preferred
}
}

extension MenuBarLayout {
/// Every token in the layout plus all tokens reachable through conditional branches (depth-capped).
func flattenedTokens(conditionals: [MenuBarLayoutConditional]) -> [MenuBarLayoutToken] {
let byID = Dictionary(conditionals.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var tokens: [MenuBarLayoutToken] = []
for token in self.lines.joined() {
token.appendFlattened(into: &tokens, conditionals: byID, depth: 0)
}
return tokens
}

/// Returns a layout with every `.conditional(id:)` token matching `id` removed from both lines,
/// or nil when nothing referenced it (so callers never materialize an unchanged stored layout).
func removingConditional(id: UUID) -> MenuBarLayout? {
var changed = false
let filtered = self.lines.map { line in
line.filter { token in
if case let .conditional(tokenID) = token, tokenID == id {
changed = true
return false
}
return true
}
}
guard changed else { return nil }
return MenuBarLayout(lines: filtered)
}
}

extension MenuBarLayoutToken {
static let maxConditionalDepth = 8

func appendFlattened(
into tokens: inout [MenuBarLayoutToken],
conditionals: [UUID: MenuBarLayoutConditional],
depth: Int)
{
if self == .hidden { return }
tokens.append(self)
guard depth < Self.maxConditionalDepth,
case let .conditional(id) = self,
let conditional = conditionals[id]
else { return }
conditional.thenToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1)
conditional.elseToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1)
}
}
Loading