diff --git a/CHANGELOG.md b/CHANGELOG.md index a364aca..662091d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project follows semantic versioning for tagged releases. ## Unreleased +### Added + +- Gave each macOS menu profile a colour and symbol. New profiles get a + stable colour from their name, with `default` in graphite, and show their + initial; **Appearance…** in a profile's actions chooses one of eight + colours and twelve symbols, applied at once. The palette leaves out red, + orange and yellow, which mean low quota. +- Added **Quota in menu bar**, an opt-in gear-menu toggle that shows the + lowest remaining Codex quota beside the menu-bar icon and keeps usage + current with the same five-minute background read as alerts. + ### Changed - Redesigned the project website as a dark ASCII page drawn live with WebGL: diff --git a/README.md b/README.md index ad81139..31d2b6d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

- Codex Profiles menu with default, personal and work profiles, each showing its remaining Codex quota beside a ring, and project shortcuts nested under their profiles. + Codex Profiles menu with default, personal and work profiles, each in its own colour and symbol and showing its remaining Codex quota beside a ring, and project shortcuts nested under their profiles.
Actual AppKit view with sample quota values. Light and dark appearance follow macOS. @@ -127,9 +127,13 @@ profile's ⋯ menu lists every window. Hover for details or press **⌘R** to refresh. The menu bar icon gains a badge when a profile runs low. Turn on -**Low-quota alerts** in the gear menu for a notification when a profile -crosses into low or critical. Alerts are off by default, and the app never -switches profiles for you. +**Quota in menu bar** in the gear menu to see the lowest remaining +percentage beside the icon, or **Low-quota alerts** for a notification when a +profile crosses into low or critical. Both are off by default, and the app +never switches profiles for you. + +Give each profile its own colour and symbol with **Appearance…** in its ⋯ +menu, so work and personal are easy to tell apart. For an unavailable reading, use **Sign in to Codex CLI…** in that profile's actions menu, then refresh. Quota follows the profile's **CLI sign-in**. diff --git a/docs/index.html b/docs/index.html index b7be7f1..967e077 100644 --- a/docs/index.html +++ b/docs/index.html @@ -639,7 +639,7 @@

work

- Codex Profiles menu bar app in dark appearance: default, personal and work profiles, each with its remaining Codex quota as a figure and a ring, personal low in orange and work critical in red with their reset times, and project workspaces nested under their profiles. + Codex Profiles menu bar app in dark appearance: default, personal and work profiles, each in its own colour with its remaining Codex quota as a figure and a ring, personal low in orange and work critical in red with their reset times, and project workspaces nested under their profiles. diff --git a/docs/llms.txt b/docs/llms.txt index 0efa53e..49f3fa4 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -225,7 +225,10 @@ Finder launches without a separately installed Codex CLI on PATH. Profile rows show the tightest Codex CLI window's remaining percentage, reported duration and a ring; a window at 25% or less is highlighted and its reset countdown replaces the row description. The profile's actions menu lists -every window. Hover for reset times. Usage is read +every window. Hover for reset times. Each profile has a colour and symbol, +automatic until chosen with Appearance… in its actions. An opt-in, default-off +Quota in menu bar toggle shows the lowest remaining percentage beside the +menu-bar icon and reads usage about every five minutes while on. Usage is read through the official app-server method account/rateLimits/read, cached in memory for 60 seconds and refreshed with Command-R; a refresh keeps the previous reading visible. Missing usage does not disable launches. The diff --git a/docs/menu-dark.png b/docs/menu-dark.png index 29f720f..56b2917 100644 Binary files a/docs/menu-dark.png and b/docs/menu-dark.png differ diff --git a/macos/CodexProfilesMenu/DESIGN.md b/macos/CodexProfilesMenu/DESIGN.md index 4dc1875..eb100bd 100644 --- a/macos/CodexProfilesMenu/DESIGN.md +++ b/macos/CodexProfilesMenu/DESIGN.md @@ -10,13 +10,14 @@ Workspaces whose profile is missing follow the groups. A hairline divides groups; rows within a group have no rules, because the nesting already shows what belongs together. Nesting and search replace a profile filter. -The 360-point popover uses semantic macOS colours, system type, monochrome -SF Symbols and the material supplied by NSPopover. The content view has no +The 360-point popover uses semantic macOS colours, system type, SF Symbols +and the material supplied by NSPopover. Colour is reserved for two jobs: +profile identity and quota emphasis. The content view has no additional effect layer. One type scale sets profile names in 13-point semibold, workspace names in 13-point regular and details in 11-point secondary text; weight, not size, carries the hierarchy. A profile row is 46 -points high: a 26-point monochrome monogram tile, the name above a detail -line, and its quota. A workspace row is a single 30-point line starting at +points high: a 26-point tile in the profile's colour holding its initial or +chosen symbol in white, the name above a detail line, and its quota. A workspace row is a single 30-point line starting at its profile's title column: a pin or folder symbol, the project name and its path on one baseline. A search result listed without its profile's row starts under the profile tiles and names its profile instead. A workspace that @@ -50,8 +51,26 @@ reset times, freshness and the Codex CLI sign-in scope. Countdowns stay in English to match the surrounding text, while clock times follow the user's locale and 12/24-hour setting. Workspace rows do not repeat account quota. Only explicitly adding a workspace asks for a folder. App-level setup, -sign-in, the Return destination, Open at Login, Low-quota alerts, About and -Quit live in the gear menu. +sign-in, the Return destination, Open at Login, Low-quota alerts, Quota in +menu bar, About and Quit live in the gear menu. + +Each profile has a colour and a symbol. Until the person chooses, `default` +is graphite and every other profile takes one of seven colours from an FNV-1a +hash of its name, so the colour stays the same on every launch and does not +shift when profiles are added; the symbol is the profile's initial. The +palette of blue, indigo, purple, pink, teal, green, brown and graphite leaves +out red, orange and yellow so they keep meaning low quota, and white symbols +keep 4.5:1 contrast on every colour. Appearance… in a profile's actions +replaces the list with an editor, in place rather than in a second popover so +dismissal and keyboard focus stay with the menu: a 40-point preview with the +profile's name and its CODEX_HOME path from the documented mapping, eight +round colour swatches, and twelve symbols, the initial and eleven SF Symbols. +A choice applies at once and updates the editor in place. Reset restores the +automatic appearance; Done, Return or Escape returns to the list. The chosen +swatch gains a ring and every choice reports its state to accessibility +clients, so a choice never depends on colour alone. A search result listed +without its profile's row shows a small square of the profile's colour beside +its name. The status item stays a monochrome template image. Apple's [menu-bar guidance](https://developer.apple.com/design/human-interface-guidelines/the-menu-bar#Menu-bar-extras) prefers a menu unless the functionality calls for richer controls. Search, @@ -101,7 +120,8 @@ removal and relocation update affected rows before reloading, so a failed refresh cannot restore an old launch identity. Superseded or removed rows cannot launch. The CLI remains the only writer of profile and binding state; the GUI persists only its pins, -recents, destination preference and Low-quota alerts choice in UserDefaults. Profile-only launches start +recents, destination preference, profile colours and symbols, and its +Low-quota alerts and menu-bar figure choices in UserDefaults. Profile-only launches start in the home directory, avoiding accidental project-binding inheritance. Explicit workspace launches pass their folder to the CLI and preserve its guard rules. @@ -143,6 +163,16 @@ at the earliest reset, waiting at most a day so an implausible reset time cannot overflow it. Remaining quota only falls within a window, so a reading stays a true upper bound until then. +Quota in menu bar, an opt-in gear-menu toggle that is off by default, adds +the lowest remaining share across initialized profiles beside the icon in +monospaced menu-bar text, such as 7%. Unlike the badge it also covers healthy +windows, and the tooltip and accessibility label then name that profile and +window. The lowest figure can only rise when its own window resets, so the +same one-shot timer re-evaluates it then. While the figure is on, usage is +read about every five minutes through the same refreshUsage path as alerts, +so it stays current; it needs no notification permission. Background reads +stop only when both alerts and the figure are off. + Low-quota alerts is an opt-in gear-menu toggle, off by default so the app never shows an unrequested permission prompt. Authorization is requested only when the toggle is turned on; denial keeps it off and explains System @@ -165,10 +195,15 @@ a threshold cannot repeat. A later critical notification replaces the low one for the same window. The body names the reset time and mentions another profile only when it has at least 50% of the same window left, no low window of its own and a reading under 15 minutes old. Clicking a -notification opens the popover. While alerts are on, a five-minute loop with -timer tolerance calls the same refreshUsage path, so the cache, two readers, -deadlines and owned-process cleanup are unchanged; turning alerts off or -Quit stops it, and no reader starts without initialized profiles. The app +notification opens the popover. While alerts or the menu-bar figure are on, +a five-minute loop with timer tolerance calls the same refreshUsage path, so +the cache, two readers, deadlines and owned-process cleanup are unchanged; +turning both off or Quit stops it, and no reader starts without initialized +profiles. Each pass lists profiles before reading usage, so profiles created +or removed outside the app join or leave the checks while the menu stays +closed. After the first profile load these listings are quiet, leaving the +status message the person may be reading alone; until then, as after a login +launch, a pass loads profiles as opening the menu would. The app never switches profiles or accounts automatically: alerts show the numbers and the person chooses. The decision logic is plain Foundation behind a notifier protocol, so tests never touch UNUserNotificationCenter. @@ -182,7 +217,8 @@ values. These are layout previews; they do not demonstrate live translucency, system permission prompts or upstream app behaviour. Previews render at 2x whatever display draws them. The AppKit interaction runner checks profile-first setup, nesting and alignment, -selection, search, dismissal, Command-Return, the Return destination and +automatic and chosen profile appearance, the appearance editor and its +persistence, the menu-bar figure toggle, selection, search, dismissal, Command-Return, the Return destination and launch arguments against an isolated executable. It switches the same open view light → dark → light, checks system-colour and accessibility notifications, and verifies opaque selection policies and accessibility state. These checks do not change global OS @@ -195,7 +231,8 @@ updates, refresh dimming, quota colours in both appearances, the usage lines in profile actions, rows being changed, right-click actions, menu enablement after AppKit validation, one-off destinations and Open at Login through a fake login item. An isolated real CLI integration verifies profile selection and strict workspace-guard handling with a fake app-server. -Alert checks cover indicator state and its bounded timer, crossing and +Alert checks cover indicator state and its bounded timer, the lowest +figure and its tooltip, background reads that follow the figure, crossing and de-duplication across jitter, early resets and missing reset times, the settled baseline after enabling, headroom choice, preference persistence, permission denial, undecided and unanswered prompts, background reads that diff --git a/macos/CodexProfilesMenu/Previews/RenderPreview.swift b/macos/CodexProfilesMenu/Previews/RenderPreview.swift index e9c0083..ab37ef2 100644 --- a/macos/CodexProfilesMenu/Previews/RenderPreview.swift +++ b/macos/CodexProfilesMenu/Previews/RenderPreview.swift @@ -6,7 +6,7 @@ import Foundation struct RenderPreview { static func main() { guard CommandLine.arguments.count >= 2 else { - FileHandle.standardError.write(Data("Usage: RenderPreview [--empty] [--dark]\n".utf8)) + FileHandle.standardError.write(Data("Usage: RenderPreview [--empty] [--dark] [--no-results] [--appearance]\n".utf8)) exit(2) } @@ -40,10 +40,15 @@ struct RenderPreview { "default": sample(18, 36), "personal": sample(43, 78), "work": sample(93, 59), ]) if let first = bindings.first { store.togglePin(first) } + // Chosen colours and symbols, as in the design; new profiles get automatic ones. + store.setAppearance(ProfileAppearance(colour: .graphite, symbol: .person), for: "default") + store.setAppearance(ProfileAppearance(colour: .green, symbol: .house), for: "personal") + store.setAppearance(ProfileAppearance(colour: .blue, symbol: .briefcase), for: "work") if CommandLine.arguments.contains("--no-results") { store.query = "does-not-exist" } let controller = WorkspaceMenuViewController(store: store, defaults: defaults) let previewView = controller.view + if CommandLine.arguments.contains("--appearance") { controller.editAppearance(of: "work") } let size = controller.preferredContentSize let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: .borderless, backing: .buffered, defer: false) window.appearance = NSAppearance(named: CommandLine.arguments.contains("--dark") ? .darkAqua : .aqua) diff --git a/macos/CodexProfilesMenu/Previews/menu-appearance.png b/macos/CodexProfilesMenu/Previews/menu-appearance.png new file mode 100644 index 0000000..d319b1d Binary files /dev/null and b/macos/CodexProfilesMenu/Previews/menu-appearance.png differ diff --git a/macos/CodexProfilesMenu/Previews/menu-dark.png b/macos/CodexProfilesMenu/Previews/menu-dark.png index 29f720f..56b2917 100644 Binary files a/macos/CodexProfilesMenu/Previews/menu-dark.png and b/macos/CodexProfilesMenu/Previews/menu-dark.png differ diff --git a/macos/CodexProfilesMenu/Previews/menu-light.png b/macos/CodexProfilesMenu/Previews/menu-light.png index bd92756..3f15ffa 100644 Binary files a/macos/CodexProfilesMenu/Previews/menu-light.png and b/macos/CodexProfilesMenu/Previews/menu-light.png differ diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CodexProfilesMenuApp.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CodexProfilesMenuApp.swift index e179adb..723c6cc 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CodexProfilesMenuApp.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CodexProfilesMenuApp.swift @@ -74,12 +74,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// The icon is a template image either way; a low quota adds a badge shape, /// and the tooltip and accessibility label name each profile and window. + /// When chosen, the lowest remaining share follows the icon in menu-bar text. private func updateStatusItem(_ indicator: QuotaIndicator) { - guard let button = statusItem?.button else { return } + guard let item = statusItem, let button = item.button else { return } let now = Date() - let label = indicator.accessibilityLabel(at: now) + let figure = alerts.showsFigure ? indicator.figure : nil + let label = indicator.accessibilityLabel(at: now, showingFigure: figure != nil) button.image = StatusItemIcon.image(for: indicator.level, accessibilityDescription: label) - button.toolTip = indicator.toolTip(at: now) + button.title = figure ?? "" + button.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + button.imagePosition = figure == nil ? .imageOnly : .imageLeading + item.length = figure == nil ? NSStatusItem.squareLength : NSStatusItem.variableLength + button.toolTip = indicator.toolTip(at: now, showingFigure: figure != nil) button.setAccessibilityLabel(label) } diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift index 7a6ce3a..17e1627 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift @@ -40,20 +40,27 @@ struct QuotaIndicator: Equatable { /// Most constrained first. let alerts: [QuotaAlert] + /// The tightest window across profiles, low or not, for the optional + /// figure beside the icon. + let lowest: QuotaAlert? /// The earliest reset among them, when the icon must be re-evaluated. let nextChange: Date? var level: QuotaLevel { alerts.map(\.level).max() ?? .normal } - init(alerts: [QuotaAlert], nextChange: Date?) { + /// The lowest remaining share, such as "7%". + var figure: String? { lowest.map { "\($0.window.remainingPercent)%" } } + + init(alerts: [QuotaAlert], lowest: QuotaAlert? = nil, nextChange: Date?) { self.alerts = alerts + self.lowest = lowest self.nextChange = nextChange } init(usage: [String: ProfileUsage], profiles: [String], at now: Date) { - let alerts = profiles.compactMap { profile -> QuotaAlert? in + let tightest = profiles.compactMap { profile -> QuotaAlert? in let state = UsageState(reading: usage[profile]) - guard let window = state.constrainingWindow(at: now), + guard let window = state.tightestWindow(at: now), let index = state.limits?.windows.firstIndex(of: window) else { return nil } return QuotaAlert(profile: profile, window: window, slot: QuotaAlert.slot(of: window, at: index)) }.sorted { lhs, rhs in @@ -61,7 +68,11 @@ struct QuotaIndicator: Equatable { ? lhs.window.remainingPercent < rhs.window.remainingPercent : lhs.profile < rhs.profile } - self.init(alerts: alerts, nextChange: alerts.compactMap(\.window.resetsAt).min().map(Date.init(timeIntervalSince1970:))) + let alerts = tightest.filter { $0.level != .normal } + // Removing a window can only raise a profile's tightest figure, so the + // lowest changes only when its own window resets. + let resets = (alerts + tightest.prefix(1)).compactMap(\.window.resetsAt) + self.init(alerts: alerts, lowest: tightest.first, nextChange: resets.min().map(Date.init(timeIntervalSince1970:))) } /// Seconds until the icon must be re-evaluated: just after the earliest @@ -77,15 +88,19 @@ struct QuotaIndicator: Equatable { return alerts.count > 4 ? shown + ["and \(alerts.count - 4) more"] : shown } - func toolTip(at now: Date) -> String { - (["Codex Profiles"] + lines(at: now)).joined(separator: "\n") + /// With the figure shown, a lowest window that is not low gets its own line. + func toolTip(at now: Date, showingFigure: Bool = false) -> String { + var shown = ["Codex Profiles"] + lines(at: now) + if showingFigure, let lowest, !alerts.contains(lowest) { shown.append("Lowest: " + lowest.description(at: now)) } + return shown.joined(separator: "\n") } /// Names each profile and window, so the state never depends on the icon alone. - func accessibilityLabel(at now: Date) -> String { + func accessibilityLabel(at now: Date, showingFigure: Bool = false) -> String { let lines = lines(at: now) - guard !lines.isEmpty else { return "Codex Profiles" } - return "Codex Profiles. Low Codex quota: " + lines.joined(separator: "; ") + var label = lines.isEmpty ? "Codex Profiles" : "Codex Profiles. Low Codex quota: " + lines.joined(separator: "; ") + if showingFigure, let lowest { label += ". Lowest: " + lowest.description(at: now) } + return label } } @@ -232,12 +247,19 @@ struct QuotaNotification: Equatable { /// asks for notification permission until the person turns alerts on. struct QuotaAlertPreferences { static let enabledKey = "lowQuotaAlerts" + static let figureKey = "menuBarQuotaFigure" let defaults: UserDefaults var isEnabled: Bool { get { defaults.bool(forKey: Self.enabledKey) } nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } } + + /// Whether the menu-bar icon shows the lowest remaining share. Off by default. + var showsFigure: Bool { + get { defaults.bool(forKey: Self.figureKey) } + nonmutating set { defaults.set(newValue, forKey: Self.figureKey) } + } } enum NotificationPermission: Equatable { @@ -288,6 +310,10 @@ final class QuotaAlertController { private var indicatorTimer: Task? var isEnabled: Bool { preferences.isEnabled } + /// Whether the menu-bar icon shows the lowest remaining share. While it + /// does, usage is read in the background as for alerts, so the figure + /// stays current; no notification permission is involved. + var showsFigure: Bool { preferences.showsFigure } /// Alerts are on, but macOS will not show them: permission was denied, or /// was never granted to this copy of the app. @@ -304,15 +330,30 @@ final class QuotaAlertController { store.onUsageChange = { [weak self] in self?.usageDidChange() } } - /// Shows the current state and resumes background reads if alerts are on. - /// Turning alerts on is the only path that asks for permission. + /// Shows the current state and resumes background reads if alerts or the + /// figure are on. Turning alerts on is the only path that asks for permission. func start() { updateIndicator(at: Date()) + updateBackgroundRefresh() guard isEnabled else { return } - store.startBackgroundUsageRefresh(every: refreshInterval) Task { await refreshPermission() } } + func setShowsFigure(_ shown: Bool) { + preferences.showsFigure = shown + updateBackgroundRefresh() + onIndicatorChange?(indicator) + } + + /// Background reads run while alerts or the figure need fresh readings. + private func updateBackgroundRefresh() { + if isEnabled || showsFigure { + store.startBackgroundUsageRefresh(every: refreshInterval) + } else { + store.stopBackgroundUsageRefresh() + } + } + /// Notices permission changed in System Settings since the last check. func refreshPermission() async { guard isEnabled else { return } @@ -324,7 +365,7 @@ final class QuotaAlertController { guard enabled else { preferences.isEnabled = false permission = nil - store.stopBackgroundUsageRefresh() + updateBackgroundRefresh() tracker.reset() return .disabled } diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift index c9e6ff0..672edd3 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift @@ -14,6 +14,8 @@ final class WorkspaceMenuViewController: NSViewController { let pinned: Bool /// Listed under its own profile's row, so it needs no profile name. let nested: Bool + /// The colour and symbol of the row's profile. + let appearance: ProfileAppearance let launching: Bool let changing: Bool let enabled: Bool @@ -45,6 +47,9 @@ final class WorkspaceMenuViewController: NSViewController { private var headerLeadingConstraint: NSLayoutConstraint? private var destination: OpenDestination private var renderedList: ListModel? + /// The profile whose colour and symbol are being chosen, in place of the list. + private var editingProfile: String? + private var appearanceEditor: AppearanceEditorView? var onPreferredContentSizeChange: ((NSSize) -> Void)? var onRequestClose: (() -> Void)? @@ -91,6 +96,12 @@ final class WorkspaceMenuViewController: NSViewController { func handleShortcut(_ event: NSEvent) -> Bool { let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.numericPad, .function]) + // The appearance editor keeps native control handling; Escape returns to the list. + if editingProfile != nil { + guard modifiers.isEmpty, event.keyCode == 53 else { return false } + closeAppearanceEditor() + return true + } let responder = view.window?.firstResponder let navigatingList = responder === searchField || responder === searchField.currentEditor() || responder is LaunchRowButton let isReturn = [36, 76].contains(event.keyCode) @@ -199,16 +210,27 @@ final class WorkspaceMenuViewController: NSViewController { private func render() { let targets = store.filteredTargets let populated = store.phase == .ready && (!store.profiles.isEmpty || !store.workspaces.isEmpty) - let shouldFocusSearch = populated && searchField.isHidden - searchField.isHidden = !populated - titleLabel.isHidden = populated + if let profile = editingProfile, !populated || !store.profiles.contains(profile) { editingProfile = nil } + let editing = editingProfile != nil + let shouldFocusSearch = populated && !editing && searchField.isHidden + searchField.isHidden = !populated || editing + titleLabel.isHidden = populated && !editing + titleLabel.stringValue = editing ? "Appearance" : "Codex Profiles" // The search field's bezel sits slightly outside the text column; the title sits on it. - headerLeadingConstraint?.constant = populated ? 10 : LaunchRowButton.contentInset - addButton.isHidden = !populated || store.profiles.isEmpty + headerLeadingConstraint?.constant = populated && !editing ? 10 : LaunchRowButton.contentInset + addButton.isHidden = !populated || store.profiles.isEmpty || editing addButton.toolTip = store.profiles.count == 1 ? "Add a workspace" : "Add a workspace or profile" refreshButton.isEnabled = !store.isRefreshing && !store.isRefreshingUsage selectedID = targets.first { $0.id == selectedID && store.canLaunch($0) }?.id selectFirstMatch(in: targets) + if let profile = editingProfile { + showAppearanceEditor(for: profile) + return + } + if appearanceEditor != nil { + appearanceEditor = nil + renderedList = nil + } let list = store.phase == .ready && !targets.isEmpty ? listModel(for: targets) : nil if let list, list == renderedList, scrollView != nil { @@ -236,17 +258,12 @@ final class WorkspaceMenuViewController: NSViewController { case .ready: if let list { showLaunchList(list) } } - let chrome = Self.headerHeight + Self.footerHeight + 2 // A list fits its rows up to 500 points; a message keeps room to breathe. - let height: CGFloat = if let document = scrollView?.documentView { - min(500, chrome + document.fittingSize.height) + if let document = scrollView?.documentView { + resize(toFit: document.fittingSize.height) } else { - min(500, max(260, chrome + (contentContainer.subviews.first.map { $0.fittingSize.height + 48 } ?? 100))) + resize(toFit: (contentContainer.subviews.first.map { $0.fittingSize.height + 48 } ?? 100), minimum: 260) } - heightConstraint?.constant = height - preferredContentSize = NSSize(width: Self.width, height: height) - onPreferredContentSizeChange?(preferredContentSize) - view.layoutSubtreeIfNeeded() updateFooter() if shouldFocusSearch, view.window?.isKeyWindow == true { focusSearch() } if let scrollView { @@ -256,6 +273,54 @@ final class WorkspaceMenuViewController: NSViewController { } } + private func resize(toFit contentHeight: CGFloat, minimum: CGFloat = 0) { + let height = min(500, max(minimum, Self.headerHeight + Self.footerHeight + 2 + contentHeight)) + heightConstraint?.constant = height + preferredContentSize = NSSize(width: Self.width, height: height) + onPreferredContentSizeChange?(preferredContentSize) + view.layoutSubtreeIfNeeded() + } + + /// Shows the editor in place of the list. Later readings update it in + /// place, so keyboard focus stays on the chosen control. + private func showAppearanceEditor(for profile: String) { + let appearance = store.appearance(for: profile) + if let appearanceEditor, appearanceEditor.profile == profile { + appearanceEditor.update(appearance) + } else { + contentContainer.subviews.forEach { $0.removeFromSuperview() } + rows.removeAll() + scrollView = nil + renderedList = nil + let editor = AppearanceEditorView(profile: profile, appearance: appearance) + editor.onChoose = { [weak self] choice in self?.store.setAppearance(choice, for: profile) } + editor.onReset = { [weak self] in self?.store.setAppearance(nil, for: profile) } + editor.onDone = { [weak self] in self?.closeAppearanceEditor() } + editor.translatesAutoresizingMaskIntoConstraints = false + contentContainer.addSubview(editor) + NSLayoutConstraint.activate([ + editor.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), + editor.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), + editor.topAnchor.constraint(equalTo: contentContainer.topAnchor), + ]) + appearanceEditor = editor + resize(toFit: editor.fittingSize.height) + } + updateFooter() + } + + /// Replaces the list with the profile's colour and symbol choices. + func editAppearance(of profile: String) { + editingProfile = profile + render() + } + + private func closeAppearanceEditor() { + editingProfile = nil + render() + focusSearch() + } + private func listModel(for targets: [LaunchTarget]) -> ListModel { let listedProfiles = Set(targets.filter { $0.workspace == nil }.map(\.profile)) return ListModel(destination: destination, rows: targets.map { item in @@ -263,6 +328,7 @@ final class WorkspaceMenuViewController: NSViewController { item: item, pinned: item.workspace.map(store.isPinned) ?? false, nested: item.workspace.map { listedProfiles.contains($0.profile) } ?? false, + appearance: store.appearance(for: item.profile), launching: store.launchingID == item.id, changing: store.isChanging(item), enabled: store.canLaunch(item) && store.launchingID == nil @@ -288,7 +354,9 @@ final class WorkspaceMenuViewController: NSViewController { /// news to report, such as a launch or a refresh failure. private func updateFooter() { let other = "⌘↩ \(destination.alternate.label)" - if store.hasStatusNews { + if editingProfile != nil { + footerLabel.stringValue = "Changes apply at once · Esc returns" + } else if store.hasStatusNews { footerLabel.stringValue = store.statusMessage } else if let item = rows.first(where: { $0.item.id == selectedID })?.item { footerLabel.stringValue = "↩ Open \(item.name) in \(destination.label) · \(other)" @@ -327,7 +395,7 @@ final class WorkspaceMenuViewController: NSViewController { stack.addArrangedSubview(divider) divider.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } - let row = LaunchRowButton(item: item, destination: list.destination, + let row = LaunchRowButton(item: item, appearance: model.appearance, destination: list.destination, usage: item.workspace == nil ? store.usageState(for: item.profile) : nil, pinned: model.pinned, nested: model.nested, selected: selectedID == item.id, launching: model.launching, changing: model.changing, enabled: model.enabled, target: self, action: #selector(launchClicked(_:))) @@ -431,6 +499,7 @@ final class WorkspaceMenuViewController: NSViewController { menu.addItem(.separator()) guard let workspace = item.workspace else { menu.addItem(menuItem("Add workspace…", #selector(addWorkspaceForProfile(_:)), symbol: "folder.badge.plus", item.profile)) + menu.addItem(menuItem("Appearance…", #selector(editAppearance(_:)), symbol: "paintpalette", item.profile)) menu.addItem(menuItem("Sign in to Codex CLI…", #selector(signInToCLI(_:)), symbol: "person.badge.key", item.profile)) return menu } @@ -532,6 +601,10 @@ final class WorkspaceMenuViewController: NSViewController { } lowQuota.isEnabled = !alerts.isChanging menu.addItem(lowQuota) + let figure = menuItem("Quota in menu bar", #selector(toggleMenuBarFigure), symbol: nil, nil) + figure.state = alerts.showsFigure ? .on : .off + figure.toolTip = "Show the lowest remaining Codex quota beside the icon. Checks usage every 5 minutes while on; never switches profiles." + menu.addItem(figure) } menu.addItem(.separator()) menu.addItem(menuItem("About Codex Profiles", #selector(showAbout), symbol: nil, nil)) @@ -642,6 +715,9 @@ final class WorkspaceMenuViewController: NSViewController { menu.addItem(create) menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.height), in: sender) } + @objc private func editAppearance(_ sender: NSMenuItem) { + if let profile = sender.representedObject as? String { editAppearance(of: profile) } + } @objc private func profileChosen(_ sender: NSMenuItem) { if let profile = sender.representedObject as? String { chooseFolder(for: profile) } } @objc private func addWorkspaceForProfile(_ sender: NSMenuItem) { if let profile = sender.representedObject as? String { chooseFolder(for: profile) } } @@ -707,6 +783,12 @@ final class WorkspaceMenuViewController: NSViewController { } } + @objc private func toggleMenuBarFigure() { + guard let alerts else { return } + alerts.setShowsFigure(!alerts.showsFigure) + store.showMessage(alerts.showsFigure ? "Lowest quota shown in the menu bar · checked every 5 minutes" : "Quota hidden from the menu bar") + } + @objc private func createProfile() { promptForProfile(addingWorkspace: false) } @objc private func createProfileForWorkspace() { promptForProfile(addingWorkspace: true) } @@ -789,7 +871,7 @@ final class LaunchRowButton: NSButton { private let avatar: RowAvatar? private let more: NSButton - init(item: LaunchTarget, destination: OpenDestination, usage: UsageState?, pinned: Bool, nested: Bool, selected: Bool, + init(item: LaunchTarget, appearance: ProfileAppearance, destination: OpenDestination, usage: UsageState?, pinned: Bool, nested: Bool, selected: Bool, launching: Bool, changing: Bool, enabled: Bool, target: AnyObject?, action: Selector?) { self.item = item self.destination = destination @@ -804,8 +886,8 @@ final class LaunchRowButton: NSButton { } detail = NSTextField(labelWithString: workspaceDetail ?? profileDetail) usageView = workspace == nil ? ProfileUsageView(usage: usage ?? UsageState()) : nil - // A monogram tile marks each profile; workspaces carry a bare symbol. - avatar = workspace == nil ? RowAvatar(monogram: item.profile) : nil + // A tile in the profile's colour marks each profile; workspaces carry a bare symbol. + avatar = workspace == nil ? RowAvatar(profile: item.profile, appearance: appearance) : nil more = NSButton(image: NSImage(systemSymbolName: "ellipsis", accessibilityDescription: "Actions for \(item.name)") ?? NSImage(), target: nil, action: nil) super.init(frame: .zero) self.target = target @@ -859,14 +941,19 @@ final class LaunchRowButton: NSButton { } rowViews.append(labels) if let workspace, !nested { - // Without its profile's row above, a workspace names its profile. + // Without its profile's row above, a workspace names its profile + // beside a dot in the profile's colour. let tag = NSTextField(labelWithString: workspace.profile) tag.font = .systemFont(ofSize: 11, weight: .medium) tag.textColor = .secondaryLabelColor tag.lineBreakMode = .byTruncatingTail - tag.setContentHuggingPriority(.required, for: .horizontal) + let marked = NSStackView(views: [ColourDot(appearance.colour), tag]) + marked.spacing = 4 + marked.alignment = .centerY + marked.setContentHuggingPriority(.required, for: .horizontal) + marked.setContentCompressionResistancePriority(.init(255), for: .horizontal) tag.setContentCompressionResistancePriority(.init(255), for: .horizontal) - rowViews.append(tag) + rowViews.append(marked) } if let usageView { rowViews.append(usageView) } if let workspace, !workspace.pathExists { @@ -1137,46 +1224,316 @@ final class RowHighlightView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { nil } } -/// A profile's leading mark: a rounded tile holding its monogram. -/// Monochrome, like the rest of the menu. +extension ProfileAppearance.Colour { + var nsColor: NSColor { NSColor(srgbRed: components.red, green: components.green, blue: components.blue, alpha: 1) } +} + +/// A profile's mark: a rounded tile in its colour holding its initial or +/// chosen symbol in white. Colour belongs to profile identity; quota keeps +/// orange and red, which the palette leaves out. final class RowAvatar: NSView { - init(monogram name: String) { + var profileAppearance: ProfileAppearance { didSet { if profileAppearance != oldValue { showGlyph(); needsDisplay = true } } } + private let initial: String + private let size: CGFloat + + init(profile: String, appearance: ProfileAppearance, size: CGFloat = LaunchRowButton.avatarSize) { + profileAppearance = appearance + initial = profile.first.map { String($0).uppercased() } ?? "" + self.size = size super.init(frame: .zero) wantsLayer = true translatesAutoresizingMaskIntoConstraints = false setAccessibilityElement(false) - let letter = NSTextField(labelWithString: name.first.map { String($0).uppercased() } ?? "") - let font = NSFont.systemFont(ofSize: 12, weight: .semibold) - letter.font = font.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: 12) } ?? font - letter.textColor = .secondaryLabelColor - letter.alignment = .center - letter.setAccessibilityElement(false) - letter.translatesAutoresizingMaskIntoConstraints = false - addSubview(letter) - let size = LaunchRowButton.avatarSize - NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: size), heightAnchor.constraint(equalToConstant: size), - letter.centerXAnchor.constraint(equalTo: centerXAnchor), letter.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) + NSLayoutConstraint.activate([widthAnchor.constraint(equalToConstant: size), heightAnchor.constraint(equalToConstant: size)]) + showGlyph() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func showGlyph() { + subviews.forEach { $0.removeFromSuperview() } + let glyph: NSView + if let name = profileAppearance.symbol.systemName, let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) { + let symbol = NSImageView(image: image) + symbol.symbolConfiguration = .init(pointSize: (size * 0.48).rounded(), weight: .medium) + symbol.contentTintColor = .white + glyph = symbol + } else { + let letter = NSTextField(labelWithString: initial) + let points = (size * 0.48).rounded() + let font = NSFont.systemFont(ofSize: points, weight: .semibold) + letter.font = font.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: points) } ?? font + letter.textColor = .white + letter.alignment = .center + glyph = letter + } + glyph.setAccessibilityElement(false) + glyph.translatesAutoresizingMaskIntoConstraints = false + addSubview(glyph) + NSLayoutConstraint.activate([glyph.centerXAnchor.constraint(equalTo: centerXAnchor), glyph.centerYAnchor.constraint(equalTo: centerYAnchor)]) + } + override var wantsUpdateLayer: Bool { true } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } override func updateLayer() { guard let layer else { return } let contrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast + layer.backgroundColor = profileAppearance.colour.nsColor.cgColor effectiveAppearance.performAsCurrentDrawingAppearance { - layer.backgroundColor = NSColor.quaternaryLabelColor.cgColor - // Increased contrast adds an outline so the shape never depends on a faint fill. + // Increased contrast outlines the tile against the popover. layer.borderWidth = contrast ? 1 : 0 - layer.borderColor = NSColor.secondaryLabelColor.cgColor + layer.borderColor = NSColor.labelColor.cgColor + } + layer.cornerCurve = .continuous + layer.cornerRadius = (size * 0.27).rounded() + } +} + +/// A small square in a profile's colour beside its name. +final class ColourDot: NSView { + init(_ colour: ProfileAppearance.Colour) { + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = colour.nsColor.cgColor + layer?.cornerRadius = 2.5 + setAccessibilityElement(false) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([widthAnchor.constraint(equalToConstant: 8), heightAnchor.constraint(equalToConstant: 8)]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +/// Chooses a profile's colour and symbol in place of the list. A choice +/// applies at once; Reset restores the automatic appearance and Done returns. +final class AppearanceEditorView: NSStackView { + let profile: String + var onChoose: ((ProfileAppearance) -> Void)? + var onReset: (() -> Void)? + var onDone: (() -> Void)? + private(set) var profileAppearance: ProfileAppearance + private let preview: RowAvatar + private var swatches: [SwatchButton] = [] + private var symbols: [SymbolChoiceButton] = [] + + init(profile: String, appearance: ProfileAppearance) { + self.profile = profile + profileAppearance = appearance + preview = RowAvatar(profile: profile, appearance: appearance, size: 40) + super.init(frame: .zero) + orientation = .vertical + alignment = .leading + spacing = 8 + edgeInsets = NSEdgeInsets(top: 14, left: LaunchRowButton.contentInset, bottom: 12, right: LaunchRowButton.contentInset) + + let name = Self.label(profile, size: 15, weight: .semibold) + name.lineBreakMode = .byTruncatingTail + // The profile's CODEX_HOME, from the documented mapping; nothing is read. + let home = Self.label(profile == "default" ? "~/.codex" : "~/.codex-\(profile)", size: 11, weight: .regular) + home.textColor = .secondaryLabelColor + let names = NSStackView(views: [name, home]) + names.orientation = .vertical + names.alignment = .leading + names.spacing = 1 + let identity = NSStackView(views: [preview, names]) + identity.spacing = 12 + identity.alignment = .centerY + addArrangedSubview(identity) + setCustomSpacing(16, after: identity) + + let colourHeading = Self.heading("Colour") + addArrangedSubview(colourHeading) + swatches = ProfileAppearance.Colour.allCases.map { colour in + let swatch = SwatchButton(colour: colour) + swatch.target = self + swatch.action = #selector(chooseColour(_:)) + return swatch + } + let palette = NSStackView(views: swatches) + palette.spacing = 8 + palette.setAccessibilityElement(true) + palette.setAccessibilityRole(.group) + palette.setAccessibilityLabel("Colour") + addArrangedSubview(palette) + setCustomSpacing(16, after: palette) + + let symbolHeading = Self.heading("Symbol") + addArrangedSubview(symbolHeading) + symbols = ProfileAppearance.Symbol.allCases.map { symbol in + let choice = SymbolChoiceButton(symbol: symbol, initial: profile.first.map { String($0).uppercased() } ?? "") + choice.target = self + choice.action = #selector(chooseSymbol(_:)) + return choice + } + let columns = 6 + let grid = NSStackView(views: stride(from: 0, to: symbols.count, by: columns).map { start in + let line = NSStackView(views: Array(symbols[start.. NSTextField { + let value = NSTextField(labelWithString: text) + value.font = .systemFont(ofSize: size, weight: weight) + return value + } + + private static func heading(_ text: String) -> NSTextField { + let value = label(text, size: 11, weight: .semibold) + value.textColor = .secondaryLabelColor + return value + } +} + +/// A round colour choice. The chosen one gains a ring in the label colour, +/// so the choice never depends on colour alone. +final class SwatchButton: NSButton { + let colour: ProfileAppearance.Colour + var isChosen = false { didSet { state = isChosen ? .on : .off; needsDisplay = true } } + private let fill = CALayer() + + init(colour: ProfileAppearance.Colour) { + self.colour = colour + super.init(frame: .zero) + title = "" + isBordered = false + setButtonType(.pushOnPushOff) + wantsLayer = true + focusRingType = .default + fill.actions = ["backgroundColor": NSNull(), "frame": NSNull()] + layer?.addSublayer(fill) + toolTip = colour.label + setAccessibilityLabel(colour.label) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([widthAnchor.constraint(equalToConstant: 30), heightAnchor.constraint(equalToConstant: 30)]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// The frame is the drawn shape, so layout spacing is the visible gap. + override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var wantsUpdateLayer: Bool { true } + override func layout() { super.layout(); needsDisplay = true } + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } + + override func updateLayer() { + guard let layer else { return } + fill.frame = bounds.insetBy(dx: 4, dy: 4) + fill.cornerRadius = fill.frame.width / 2 + fill.backgroundColor = colour.nsColor.cgColor + layer.cornerRadius = bounds.width / 2 + layer.borderWidth = isChosen ? 2 : 0 + effectiveAppearance.performAsCurrentDrawingAppearance { + layer.borderColor = NSColor.labelColor.cgColor + } + } +} + +/// One symbol choice. The chosen symbol sits on the profile's colour in +/// white, as it will in the list. +final class SymbolChoiceButton: NSButton { + let symbol: ProfileAppearance.Symbol + private var chosen = false + private var colour = ProfileAppearance.Colour.graphite + + init(symbol: ProfileAppearance.Symbol, initial: String) { + self.symbol = symbol + super.init(frame: .zero) + isBordered = false + setButtonType(.pushOnPushOff) + wantsLayer = true + // A symbol this Mac lacks falls back to the initial, like the tile does. + if let name = symbol.systemName, let symbolImage = NSImage(systemSymbolName: name, accessibilityDescription: symbol.label) { + title = "" + image = symbolImage + symbolConfiguration = .init(pointSize: 14, weight: .regular) + imagePosition = .imageOnly + } else { + let font = NSFont.systemFont(ofSize: 14, weight: .semibold) + self.font = font.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: 14) } ?? font + title = initial } + toolTip = symbol.label + setAccessibilityLabel(symbol.label) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([widthAnchor.constraint(equalToConstant: 46), heightAnchor.constraint(equalToConstant: 32)]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show(chosen: Bool, colour: ProfileAppearance.Colour) { + self.chosen = chosen + self.colour = colour + state = chosen ? .on : .off + contentTintColor = chosen ? .white : .labelColor + needsDisplay = true + } + + /// The frame is the drawn shape, so layout spacing is the visible gap. + override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var wantsUpdateLayer: Bool { true } + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } + + override func updateLayer() { + guard let layer else { return } layer.cornerCurve = .continuous - layer.cornerRadius = 7 + layer.cornerRadius = 8 + effectiveAppearance.performAsCurrentDrawingAppearance { + layer.backgroundColor = (chosen ? colour.nsColor : NSColor.labelColor.withAlphaComponent(0.06)).cgColor + } } } diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift index 48b2cb3..96e4b56 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift @@ -101,3 +101,65 @@ struct WorkspaceBinding: Decodable, Equatable, Identifiable { || path.localizedCaseInsensitiveContains(trimmed) } } + +/// A profile's colour and symbol in the menu, so profiles are told apart at a +/// glance. The palette leaves out red, orange and yellow, which mean low quota. +struct ProfileAppearance: Equatable { + enum Colour: String, CaseIterable { + case blue, indigo, purple, pink, teal, green, brown, graphite + + var label: String { rawValue.capitalized } + + /// sRGB components. White symbols keep at least 4.5:1 contrast on each, + /// which a unit test checks. + var components: (red: Double, green: Double, blue: Double) { + switch self { + case .blue: (0.039, 0.424, 0.859) + case .indigo: (0.306, 0.298, 0.769) + case .purple: (0.537, 0.267, 0.671) + case .pink: (0.780, 0.145, 0.357) + case .teal: (0.043, 0.478, 0.518) + case .green: (0.141, 0.522, 0.231) + case .brown: (0.525, 0.400, 0.290) + case .graphite: (0.451, 0.451, 0.471) + } + } + } + + enum Symbol: String, CaseIterable { + case monogram, person, house, briefcase, building, book, flask, code, terminal, globe, leaf, bolt + + var label: String { + switch self { + case .monogram: "Initial" + case .code: "Code brackets" + default: rawValue.capitalized + } + } + + /// The SF Symbol, or nil for the profile's initial. + var systemName: String? { + switch self { + case .monogram: nil + case .building: "building.2" + case .code: "chevron.left.forwardslash.chevron.right" + default: rawValue + } + } + } + + var colour: Colour + var symbol: Symbol + + /// Before a choice: graphite for `default`, the stock ChatGPT session, and + /// otherwise a colour derived from the name, so it stays the same on every + /// launch and does not shift when other profiles are added. + static func automatic(for profile: String) -> Self { + guard profile != "default" else { return Self(colour: .graphite, symbol: .monogram) } + let choices = Colour.allCases.filter { $0 != .graphite } + // FNV-1a, because Swift's hashValue changes between launches. + var hash: UInt32 = 2_166_136_261 + for byte in profile.utf8 { hash = (hash ^ UInt32(byte)) &* 16_777_619 } + return Self(colour: choices[Int(hash % UInt32(choices.count))], symbol: .monogram) + } +} diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift index 011f42f..1366390 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift @@ -41,8 +41,13 @@ final class WorkspaceStore { private let defaults: UserDefaults private let recentsKey = "workspaceRecents" private let pinsKey = "workspacePins" + private let appearanceKey = "profileAppearance" private var refreshTask: Task? private var refreshRequested = false + /// Whether the refresh in flight reports progress and its outcome in the + /// status message. Background passes do not, so they never replace a + /// message the person is reading; a reporting caller that joins them does. + private var refreshReportsStatus = true private var hasLoaded = false private var usageTask: Task? private var usageRefreshRequested = false @@ -78,6 +83,24 @@ final class WorkspaceStore { (defaults.stringArray(forKey: pinsKey) ?? []).contains(workspace.id) } + /// The profile's chosen colour and symbol, or its automatic appearance. + func appearance(for profile: String) -> ProfileAppearance { + let automatic = ProfileAppearance.automatic(for: profile) + guard let saved = defaults.dictionary(forKey: appearanceKey)?[profile] as? [String: String] else { return automatic } + return ProfileAppearance( + colour: saved["colour"].flatMap(ProfileAppearance.Colour.init) ?? automatic.colour, + symbol: saved["symbol"].flatMap(ProfileAppearance.Symbol.init) ?? automatic.symbol + ) + } + + /// Saves a profile's appearance; nil restores the automatic one. + func setAppearance(_ appearance: ProfileAppearance?, for profile: String) { + var saved = defaults.dictionary(forKey: appearanceKey) ?? [:] + saved[profile] = appearance.map { ["colour": $0.colour.rawValue, "symbol": $0.symbol.rawValue] } + defaults.set(saved, forKey: appearanceKey) + notify() + } + func usageState(for profile: String) -> UsageState { UsageState(reading: usage[profile], isRefreshing: readingUsage.contains(profile)) } @@ -182,9 +205,10 @@ final class WorkspaceStore { } private func refreshUsageInBackground() async { - // A login launch has not listed profiles yet; the menu refreshes the - // list whenever it opens. - if !hasLoaded { await refresh() } + // Each pass lists profiles first, so profiles created or removed + // outside the app join or leave the checks while the menu stays closed. + // It does so quietly, leaving the footer's message alone. + await refresh(quietly: hasLoaded) guard !profiles.isEmpty, !Task.isCancelled else { return } await refreshUsage() } @@ -202,16 +226,18 @@ final class WorkspaceStore { } @discardableResult - func refresh() async -> Bool { + func refresh(quietly: Bool = false) async -> Bool { if let refreshTask { refreshRequested = true + if !quietly { refreshReportsStatus = true } return await refreshTask.value } + refreshReportsStatus = !quietly let task = Task { @MainActor in isRefreshing = true if !hasLoaded { phase = .loading } - statusMessage = "Refreshing profiles…" + if refreshReportsStatus { statusMessage = "Refreshing profiles…" } notify() var succeeded = false @@ -240,11 +266,11 @@ final class WorkspaceStore { guardMode = response.guardMode hasLoaded = true phase = .ready - statusMessage = restingMessage + if refreshReportsStatus { statusMessage = restingMessage } return true } catch { if !hasLoaded { phase = .failed(error.localizedDescription) } - statusMessage = "Could not refresh: \(error.localizedDescription)" + if refreshReportsStatus { statusMessage = "Could not refresh: \(error.localizedDescription)" } return false } } diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift index 069296b..1d635ae 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift @@ -345,6 +345,48 @@ struct MenuInteractionTests { context: nil, eventNumber: 0, clickCount: 1, pressure: 1)!)?.items.contains { $0.title == "Copy path" } == true, "right-clicking a row must offer the same actions as its actions button") + // Each profile has a colour and symbol, chosen in an editor that replaces the list. + let automatic = ProfileAppearance.automatic(for: "work") + let personalTile = descendants(controller.view).compactMap { $0 as? RowAvatar }.first! + expect(automatic == ProfileAppearance.automatic(for: "work") && automatic.symbol == .monogram && automatic.colour != .graphite + && ProfileAppearance.automatic(for: "default").colour == .graphite && personalTile.profileAppearance == store.appearance(for: "personal"), + "profiles must start with a stable automatic colour and their initial, graphite for default") + let appearanceMenu = controller.actionsMenu(for: .profile("work")) + appearanceMenu.performActionForItem(at: appearanceMenu.indexOfItem(withTitle: "Appearance…")) + let editor = descendants(controller.view).compactMap { $0 as? AppearanceEditorView }.first! + expect(descendants(controller.view).compactMap { $0 as? LaunchRowButton }.isEmpty && search.isHidden, + "the appearance editor must replace the list and search") + let swatches = descendants(editor).compactMap { $0 as? SwatchButton } + let symbolChoices = descendants(editor).compactMap { $0 as? SymbolChoiceButton } + expect(swatches.count == 8 && symbolChoices.count == 12 + && !swatches.contains { ["red", "orange", "yellow"].contains($0.colour.rawValue) }, + "the editor must offer eight colours, leaving out the quota colours, and twelve symbols") + expect(swatches.filter { $0.state == .on }.map(\.colour) == [automatic.colour] + && symbolChoices.filter { $0.state == .on }.map(\.symbol) == [.monogram], + "the current colour and symbol must be marked as chosen") + swatches.first { $0.colour == .teal }!.performClick(nil) + symbolChoices.first { $0.symbol == .flask }!.performClick(nil) + let chosen = ProfileAppearance(colour: .teal, symbol: .flask) + expect(store.appearance(for: "work") == chosen && WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults).appearance(for: "work") == chosen, + "choices must apply at once and persist") + expect(descendants(controller.view).compactMap { $0 as? AppearanceEditorView }.first === editor + && swatches.filter { $0.state == .on }.map(\.colour) == [.teal], + "a choice must update the editor in place, so focus stays put") + expect(controller.handleShortcut(key(53)) && !search.isHidden, "Escape must return from the editor to the list") + let chosenTile = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first { $0.item == .profile("work") } + .flatMap { descendants($0).compactMap { $0 as? RowAvatar }.first }! + expect(chosenTile.profileAppearance == chosen, "the list must show the chosen colour and symbol") + controller.editAppearance(of: "work") + descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Reset" }!.performClick(nil) + expect(store.appearance(for: "work") == automatic, "Reset must restore the automatic appearance") + descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Done" }!.performClick(nil) + expect(descendants(controller.view).contains { $0 is LaunchRowButton } && !descendants(controller.view).contains { $0 is AppearanceEditorView }, + "Done must return to the list") + store.query = "first" + let taggedRow = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first! + expect(descendants(taggedRow).contains { $0 is ColourDot }, "a search result without its profile row must mark the profile's colour") + store.query = "" + // A row action opens the other destination without changing the default. defaults.set(OpenDestination.terminal.rawValue, forKey: "openDestination") let login = FakeLoginItem() @@ -429,6 +471,15 @@ struct MenuInteractionTests { try await waitUntil { !alerts.isEnabled } expect(!alerts.isEnabled && !store.isRefreshingUsageInBackground && item("Low-quota alerts", in: alertsController.settingsMenu()).state == .off, "choosing it again must turn alerts and background reads off") + settings = alertsController.settingsMenu() + expect(item("Quota in menu bar", in: settings).state == .off, "the menu-bar figure must start off") + settings.performActionForItem(at: settings.indexOfItem(withTitle: "Quota in menu bar")) + expect(alerts.showsFigure && item("Quota in menu bar", in: alertsController.settingsMenu()).state == .on + && store.isRefreshingUsageInBackground && notifier.requests == 1, + "choosing Quota in menu bar must show the figure and keep readings fresh without asking for permission again") + settings = alertsController.settingsMenu() + settings.performActionForItem(at: settings.indexOfItem(withTitle: "Quota in menu bar")) + expect(!alerts.showsFigure && !store.isRefreshingUsageInBackground, "choosing it again must hide the figure and stop background reads") // The status icon stays a labelled template image; low quota changes // its shape, never only its colour. diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift index 5b97a8e..249e17f 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift @@ -15,6 +15,7 @@ enum QuotaAlertTests { await unansweredPermissionStopsWaiting() print("Checking background usage refresh only while alerts are on…") try await backgroundRefreshFollowsAlerts() + await figureKeepsReadingsFresh() print("Low-quota indicator, crossing, headroom and preference tests passed.") } @@ -58,6 +59,20 @@ enum QuotaAlertTests { "a window must leave the icon once its reset passes, without a new reading") expect(QuotaIndicator(usage: ["personal": usage["personal"]!], profiles: ["personal"], at: now).level == .low, "a low window alone must show the low badge") + expect(indicator.figure == "8%" && indicator.lowest?.profile == "work" && empty.figure == nil, + "the optional figure must be the lowest remaining share across profiles, and absent without readings") + + // The figure also covers a healthy window, which the badge ignores. + let healthy = QuotaIndicator(usage: ["healthy": usage["healthy"]!], profiles: ["healthy"], at: now) + let healthyReset = UsageFormat.clock(now.addingTimeInterval(600), relativeTo: now) + expect(healthy.level == .normal && healthy.alerts.isEmpty && healthy.figure == "80%" && healthy.nextChange == now.addingTimeInterval(600), + "a healthy window must leave the icon unbadged but give the figure, refreshed at its reset") + expect(healthy.toolTip(at: now) == "Codex Profiles" + && healthy.toolTip(at: now, showingFigure: true) == "Codex Profiles\nLowest: healthy: 5h limit at 80%, resets \(healthyReset)" + && healthy.accessibilityLabel(at: now, showingFigure: true) == "Codex Profiles. Lowest: healthy: 5h limit at 80%, resets \(healthyReset)", + "a shown figure must be named in the tooltip and to VoiceOver") + expect(indicator.toolTip(at: now, showingFigure: true) == indicator.toolTip(at: now), + "a lowest window already listed as low must not be repeated") let reached = QuotaIndicator(usage: ["work": reading([window(0, 300, resetIn: 600)])], profiles: ["work"], at: now) expect(reached.lines(at: now)[0].hasPrefix("work: 5h limit reached, resets "), "an exhausted window must say reached") @@ -230,6 +245,9 @@ enum QuotaAlertTests { "turning alerts on must persist under a stable key") QuotaAlertPreferences(defaults: defaults).isEnabled = false expect(!QuotaAlertPreferences(defaults: reopened).isEnabled, "turning alerts off must persist") + expect(!QuotaAlertPreferences(defaults: defaults).showsFigure, "the menu-bar figure must default to off") + QuotaAlertPreferences(defaults: defaults).showsFigure = true + expect(reopened.bool(forKey: "menuBarQuotaFigure"), "showing the figure must persist under a stable key") } private static func controllerAsksOnlyWhenTurnedOn() async throws { @@ -405,11 +423,21 @@ enum QuotaAlertTests { store.loadPreview([], profiles: ["work"], usage: ["work": reading([window(50, 300, resetIn: 3600, from: Date())], checkedAt: Date().addingTimeInterval(-120))]) try await waitUntil { calls(fixture) == first + 1 && !store.isRefreshingUsage } expect(!(try fixture.isRunning("work")), "background reads must stop their app-servers") + // Each pass lists profiles quietly, so one created outside the app + // joins the checks without replacing the footer's message. + store.showMessage("Opened work in ChatGPT") + try fixture.write("work\npersonal\n", to: "profiles") + try fixture.write(#"{"guard_mode":"off","bindings":[]}"#, to: "workspaces.json") + try await waitUntil { store.usage["personal"] != nil && !store.isRefreshingUsage } + expect(store.profiles == ["work", "personal"] && store.usage["personal"] != nil, + "a profile created outside the app must join the background checks") + expect(store.statusMessage == "Opened work in ChatGPT", "background passes must not replace the footer's message") _ = await alerts.setEnabled(false) try await Task.sleep(nanoseconds: 150_000_000) + let stopped = calls(fixture) store.loadPreview([], profiles: ["work"], usage: ["work": reading([window(50, 300, resetIn: 3600, from: Date())], checkedAt: Date().addingTimeInterval(-120))]) try await Task.sleep(nanoseconds: 250_000_000) - expect(calls(fixture) == first + 1, "turning alerts off must stop background reads") + expect(calls(fixture) == stopped, "turning alerts off must stop background reads") let empty = try UsageTests.Fixture() defer { empty.cleanup() } @@ -452,6 +480,33 @@ enum QuotaAlertTests { func post(_ notification: QuotaNotification) { posted.append(notification) } } + /// The figure keeps readings fresh without alerts or notification permission. + private static func figureKeepsReadingsFresh() async { + let suite = WorkspaceStore.testSuiteName("QuotaFigure") + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + // Fresh readings stay cached, so background passes start no reader. + let store = WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults) + let start = Date() + store.loadPreview([], profiles: ["work"], usage: ["work": reading([window(64, 300, resetIn: 3600, from: start)], checkedAt: start)]) + let notifier = FakeNotifier() + let alerts = QuotaAlertController(store: store, notifier: notifier, defaults: defaults, refreshInterval: 3600) + var redraws = 0 + alerts.onIndicatorChange = { _ in redraws += 1 } + alerts.start() + expect(!alerts.showsFigure && !store.isRefreshingUsageInBackground, "the figure must start off without background reads") + alerts.setShowsFigure(true) + expect(alerts.showsFigure && QuotaAlertPreferences(defaults: defaults).showsFigure && store.isRefreshingUsageInBackground && redraws > 0, + "showing the figure must persist, redraw the icon and keep readings fresh") + expect(notifier.permissionChecks == 0 && notifier.requests == 0, "the figure must never involve notification permission") + expect(alerts.indicator.figure == "64%", "the figure must show the lowest share even when nothing is low") + expect(await alerts.setEnabled(false) == .disabled && store.isRefreshingUsageInBackground, + "turning alerts off must keep the reads the figure needs") + alerts.setShowsFigure(false) + expect(!store.isRefreshingUsageInBackground, "hiding the figure with alerts off must stop background reads") + await store.stopUsageRefresh() + } + private static func calls(_ fixture: UsageTests.Fixture) -> Int { (try? fixture.read("calls"))?.split(whereSeparator: \.isNewline).count ?? 0 } diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift index e8e7b22..cc4c1c2 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift @@ -17,6 +17,7 @@ struct CodexProfilesMenuTests { try await testProfileOnlyLaunches() testTerminalPermissionErrorsAreActionable() testProfileNames() + testProfileAppearanceContrast() try await testProcessDrainsBothStreams() try await testProcessTerminationAndLaunchFailure() try await testCreatesAndBindsWithRealCLI() @@ -101,6 +102,22 @@ struct CodexProfilesMenuTests { expect(profiles == ["default", "personal", "work"], "profiles were not parsed") } + /// White symbols sit on every profile colour, so each must keep the + /// WCAG 4.5:1 contrast for normal text that the initial needs. + private static func testProfileAppearanceContrast() { + func linear(_ value: Double) -> Double { value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) } + for colour in ProfileAppearance.Colour.allCases { + let (red, green, blue) = colour.components + let luminance = 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + let contrast = 1.05 / (luminance + 0.05) + expect(contrast >= 4.5, "white on \(colour.rawValue) has \(contrast):1 contrast, below 4.5:1") + } + expect(ProfileAppearance.automatic(for: "default") == ProfileAppearance(colour: .graphite, symbol: .monogram) + && ProfileAppearance.automatic(for: "client-a") == ProfileAppearance.automatic(for: "client-a") + && ProfileAppearance.automatic(for: "client-a").colour != .graphite, + "automatic colours must be stable, graphite only for default") + } + private static func testProfileNames() { for name in ["default", "work", "Work-2", "a.b_c"] { expect(CLIClient.isValidProfileName(name), "valid profile was rejected: \(name)") diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift index 93eeeb9..7ecede4 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift @@ -262,6 +262,10 @@ enum UsageTests { fixture='\#(root.path)' mode='\#(mode)' if [ "$1" = --version ]; then printf 'codex-cli 0.152.1\n'; exit; fi + # Listing answers from files a test writes and fails without them; + # it is not a usage read, so it is not counted. + if [ "$1" = list ]; then [ -f "$fixture/profiles" ] && cat "$fixture/profiles"; exit $?; fi + if [ "$1" = workspace ]; then [ -f "$fixture/workspaces.json" ] && cat "$fixture/workspaces.json"; exit $?; fi profile=${2:-work} if [ "$1" = app-server ]; then profile=work; fi printf '%s\n' "$@" > "$fixture/arguments-$profile" diff --git a/macos/README.md b/macos/README.md index 8dc2b54..9ef9cfc 100644 --- a/macos/README.md +++ b/macos/README.md @@ -45,7 +45,14 @@ Every row's actions menu offers **Open in ChatGPT** and **Open in Terminal**, which use that destination once without changing the default. **Return opens in** in the gear menu sets the default; ChatGPT is the default until you change it. Profile rows also list each Codex window with its reset time, and -offer **Add workspace…** for that profile and **Sign in to Codex CLI…**. +offer **Add workspace…** for that profile, **Appearance…** and **Sign in to +Codex CLI…**. + +Each profile has a coloured tile. New profiles get a colour from their name, +with `default` in graphite, and show their initial. **Appearance…** lets you +choose one of eight colours and twelve symbols; changes apply at once and +**Done** or Escape returns to the list. Red, orange and yellow are left out +because they mean low quota. Actions that cannot apply, such as revealing a missing folder, are disabled. Search receives focus when the menu opens. Typing selects the first @@ -111,6 +118,12 @@ stays an upper bound on what is left: the badge never overstates the remaining quota, but a profile can run lower than it shows until the next reading. The icon needs no permission and starts no reads of its own. +**Quota in menu bar** in the gear menu shows the lowest remaining percentage +across your profiles beside the icon, such as `7%`, even when nothing is low. +It is off by default. While it is on, the app checks usage about every five +minutes, like alerts, so the figure stays current; it needs no notification +permission and never switches profiles. + **Low-quota alerts** in the gear menu adds macOS notifications. It is off by default, and macOS asks for notification permission only when you turn it on. If notifications are not allowed, alerts stay off and the app explains how to @@ -132,11 +145,13 @@ profile has at least 50% of the same window left in a reading under 15 minutes old, it also says so, for example `personal has 70% left.` Clicking the notification opens the menu. -While alerts are on, the app checks usage about every five minutes through -the same reader, cache, two-reader limit, deadlines and process cleanup. -Checks stop when alerts are turned off or the app quits, and no reader starts -when no profile is initialized. A login launch lists profiles once; profiles -created outside the app join the checks the next time the menu opens. +While alerts or **Quota in menu bar** are on, the app checks usage about +every five minutes through the same reader, cache, two-reader limit, +deadlines and process cleanup. Checks stop when both are off or the app +quits, and no reader starts when no profile is initialized. Each check lists +profiles first, so profiles created or removed outside the app join or leave +the checks without opening the menu. Once profiles have loaded, these checks +leave the menu's status line alone. The app never switches profiles or accounts for you. It shows the numbers and you choose which profile to open. This is deliberate: Codex Profiles @@ -172,6 +187,7 @@ provided by NSPopover and depends on macOS appearance and accessibility settings Native profile and workspace menu in light appearance Native profile and workspace menu in dark appearance +Choosing a profile's colour and symbol in light appearance See the [design notes](CodexProfilesMenu/DESIGN.md) for Apple guidance and interaction behaviour. Re-render the previews after changing the view: @@ -179,6 +195,7 @@ interaction behaviour. Re-render the previews after changing the view: ```sh scripts/macos/render-menu-preview.sh macos/CodexProfilesMenu/Previews/menu-light.png scripts/macos/render-menu-preview.sh macos/CodexProfilesMenu/Previews/menu-dark.png --dark +scripts/macos/render-menu-preview.sh macos/CodexProfilesMenu/Previews/menu-appearance.png --appearance ``` ## Build locally diff --git a/test/macos/menu-app-test.sh b/test/macos/menu-app-test.sh index 8eba1f0..7f382d3 100755 --- a/test/macos/menu-app-test.sh +++ b/test/macos/menu-app-test.sh @@ -110,6 +110,7 @@ swiftc \ -o "$TMP_DIR/RenderPreview" "$TMP_DIR/RenderPreview" "$TMP_DIR/menu-light.png" "$TMP_DIR/RenderPreview" "$TMP_DIR/menu-dark.png" --dark +"$TMP_DIR/RenderPreview" "$TMP_DIR/menu-appearance.png" --appearance printf 'Native light and dark preview backdrop checks passed.\n' MENU_APP_BUILD_DIR="$TMP_DIR/app-build" "$BUILD_SCRIPT"