diff --git a/CHANGELOG.md b/CHANGELOG.md index c541d14..a0beaf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project follows semantic versioning for tagged releases. ### Changed +- Redesigned the macOS menu around profiles. Each profile now leads a group + with its workspaces nested beneath it, replacing the profile filter, and a + search selects its first match so Return's target is visible. Clicking a + row opens it; ⌘Return and ⌘-click open the other destination, and the gear + menu's **Return opens in** replaces the ChatGPT/Terminal control. Rows drop + their Open buttons and reveal actions on hover, selection or right-click. + Each profile shows its tightest Codex window as a figure and ring, in a + darker orange or red when low so the number keeps its contrast, and the + profile's actions list every window with its reset time. The popover is + 360 points wide, and previews render at 2x. - Made the macOS menu-bar app the README's main entry point, with native previews, source installation and menu workflows before the CLI reference. Followed Best-README-Template's project, setup and contributor structure. diff --git a/README.md b/README.md index ace9b73..ad81139 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

- Codex Profiles menu bar with personal and work profiles, remaining Codex quota, reset times and pinned project shortcuts. + 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.
Actual AppKit view with sample quota values. Light and dark appearance follow macOS. @@ -111,8 +111,9 @@ notarisation, see the [macOS build guide](https://github.com/Ducksss/codex-profi 1. Click the menu bar icon. Existing profiles appear automatically. 2. Choose **New profile…** in the gear menu, or **Create profile…** if the list is empty. Give it a name such as `personal` or `work`. -3. Select **ChatGPT**, click **Open** beside the profile and sign in inside - that window when prompted. Choose **Terminal** to start Codex CLI instead. +3. Click the profile to open it in ChatGPT and sign in inside that window + when prompted. ⌘-click it, or press ⌘Return, to start Codex CLI in + Terminal instead. Different names can stay open side by side. Reopening a name keeps its local data, and `default` opens your normal ChatGPT session. Enable **Open at Login** @@ -120,8 +121,10 @@ in the gear menu to keep the app available after you log in. ### Check quota before opening a profile -**Codex left** shows remaining CLI quota. Low readings turn orange or red -and show their reset time. Hover for details or press **⌘R** to refresh. +Each profile shows the remaining CLI quota of its tightest window beside a +ring. Low readings turn orange or red and show their reset time, and the +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 @@ -137,10 +140,10 @@ for supported accounts and troubleshooting. ### Open a project with its profile -Choose **Add workspace…**, select a folder and assign a profile. Pin the -projects you use often, then search and click **Open** to return to them. -The row's actions menu lets you change the profile, repair a moved folder or -remove the shortcut. Bindings are local metadata and do not change project files. +Choose **+**, select a folder and assign a profile. The project appears under +its profile; pin the ones you use often, then search and click one, or press +Return, to open it. The row's ⋯ menu, or a right-click, lets you change the +profile, repair a moved folder or remove the shortcut. Bindings are local metadata and do not change project files.

Keyboard shortcuts and macOS controls @@ -148,6 +151,7 @@ remove the shortcut. Bindings are local metadata and do not change project files | Shortcut | Action | | --- | --- | | ↑ / ↓, then Return | Select and open a profile or project | +| ⌘Return or ⌘-click | Open in the other destination: Terminal, unless the gear menu's **Return opens in** chooses it for Return | | ⌘1–9 | Open the corresponding visible row | | ⌘K | Focus search | | ⌘R | Refresh profiles, projects and quota | diff --git a/docs/index.html b/docs/index.html index db1ddb6..1783ccf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1371,14 +1371,14 @@

What it does

Codex Profiles.app lists your profiles and project bindings in the menu bar, opens any of them in ChatGPT or Terminal, and shows each profile's remaining Codex quota before you switch.

diff --git a/docs/llms.txt b/docs/llms.txt index 61d2be1..bcd8f56 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -199,16 +199,17 @@ confused with the ChatGPT mode named Work. Since 1.3.0, source checkouts include an optional native menu-bar app for macOS 13 or newer. It creates and opens profiles in ChatGPT or Terminal without requiring a project folder. Workspace bindings are optional project -shortcuts. Search and profile filtering cover both profiles and workspaces. +shortcuts listed under their profile. Search covers both profiles and workspaces. The menu follows macOS appearance, accent, contrast, transparency and Reduce Motion preferences; profile operations use the app's bundled codex-profile. Terminal launches, Codex CLI sign-in and quota readings need the official Codex CLI. CLI discovery supports current and legacy Desktop bundle layouts, including Finder launches without a separately installed Codex CLI on PATH. -Profile rows show remaining Codex CLI quota with compact meters and reported -window durations; a window at 25% or less is highlighted and its reset -countdown replaces the row description. Hover for reset times. Usage is read +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 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/macos/CodexProfilesMenu/DESIGN.md b/macos/CodexProfilesMenu/DESIGN.md index f406553..4dc1875 100644 --- a/macos/CodexProfilesMenu/DESIGN.md +++ b/macos/CodexProfilesMenu/DESIGN.md @@ -1,53 +1,65 @@ # Native companion -A compact profile and workspace switcher, built with AppKit and the existing +A compact profile and workspace launcher, built with AppKit and the existing Bash CLI. -The single-line header identifies Codex Profiles and offers a gear menu. -Native search matches profile names, project names and paths; a profile filter -and ChatGPT/Terminal segmented control sit immediately below it. Profiles appear -first and open without a folder. Pinned workspaces follow, then other workspaces -ordered by last successful launch. Never call an unopened binding “recent”. +The header is one line: native search and a gear menu. Search matches profile +names, project names and paths. Each profile leads its own group and opens +without a folder; its workspaces sit beneath it, pinned first and then ordered +by last successful launch. Never call an unopened binding “recent”. +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 400-point popover uses semantic macOS colours, system type, monochrome +The 360-point popover uses semantic macOS colours, system type, monochrome SF Symbols and the material supplied by NSPopover. The content view has no -additional effect layer. Header, search and filters form one block above a -single rule. One type scale sets the title in 13-point semibold, row names in -13-point medium, details in 11-point secondary text and section headings in -11-point semibold; weight, not size, carries the hierarchy. Each row starts -with a 22-point monochrome mark: a monogram disc for a profile, a rounded -folder or pin tile for a workspace. The header mark and title share the rows' -mark and title columns. Rows show the profile name or project name and path, -a small Open button, and related actions. Every available row is 48 points -high; an unavailable row adds 16 points for its explanation and retains repair -actions. Thin rules divide rows within a group, start at the title column and -hide beside a highlighted row; space above each heading separates groups. -Hover and keyboard selection paint an inset, rounded plate. Rows -remain unselected on opening and after search changes. Arrow-key selection -uses a system accent fill, a semantic outline and an accessibility selected -state. Height follows content up to 500 points; longer lists scroll. Optional -Add Workspace sits in the footer. The empty state offers Create Profile; -creating a profile makes it immediately available without a folder picker. -An inline **Codex left** column aligns beside profile names and before Open. -Each reported window shows its duration, a 4-point capacity meter and a -right-aligned monospaced percentage whose smaller, quieter percent sign keeps -the column reading as figures. Meters use the secondary label colour, -orange at 25% or less and red at 10% or less, where the number also turns red; -the number always carries the value. A low window replaces the row's -destination description with its name and reset countdown. Tooltips and the -row's accessibility label include relative and absolute 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, -Open at Login, Low-quota alerts, About and Quit live in the gear menu. +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 +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 +cannot open grows to 44 points to explain why on a second line, always shows +its actions and offers Locate… when its folder is missing. +Rows have no Open buttons: clicking a row opens it. The ⋯ actions button +appears with the hover or selection plate, so resting rows stay quiet, and +it stays in the accessibility tree. Right-click and Control-click show the +same actions. Hover and keyboard selection paint an inset, rounded plate. +Rows remain unselected on opening; while searching, the first available match +is selected so Return's target is visible before it is pressed. Arrow-key +selection uses a system accent fill, a semantic outline and an accessibility +selected state. Height follows content up to 500 points; longer lists scroll. +The footer says what Return and Command-Return open, naming the selection, or +reports the latest outcome, beside Add and Refresh buttons. The empty state +replaces search with the app's name and offers Create Profile; creating a +profile makes it immediately available without a folder picker. +Each profile row shows its tightest Codex window: the remaining percentage in +13-point tabular figures with a smaller percent sign, the window's duration +below it, such as “5h left”, and a 20-point ring filled clockwise from the top +with the remaining share. Normal rings use the secondary label colour. At 25% +or less the ring and number turn orange, and at 10% or less red. In light +mode these are darker than systemOrange and systemRed, so a coloured figure +keeps 4.5:1 contrast on the popover and on the hover plate; dark mode uses +lighter tints. The number always carries the value. A low window replaces +the row's destination description with its name and reset countdown. The +profile's actions menu lists every window with its remaining share and reset +clock time, and when it was checked, as disabled information lines. +Tooltips and the row's accessibility label include relative and absolute +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. 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, -profile selection and destination switching justify a popover here. Follow the +nested rows and inline quota justify a popover here. Follow the [popover guidance](https://developer.apple.com/design/human-interface-guidelines/popovers) -for related tasks, anchoring, minimal size and dismissal. The monochrome header, -gear, separators and inline Open actions interpret the user's visual reference; -they are not dimensions or a layout mandated by Apple. +for related tasks, anchoring, minimal size and dismissal. The search-first +header, nested rows, quota rings and hover-revealed actions interpret the +user's visual reference; they are not dimensions or a layout mandated by Apple. The app never overrides production appearance. The status icon is a template image; controls, SF Symbols, menus, alerts and NSPopover inherit macOS light, @@ -65,15 +77,17 @@ full label colour and outlines row marks. Native controls retain focus, keyboard behaviour and labelled actions for accessibility clients. Keyboard: search receives focus on opening; arrows select available profiles -or projects. Return launches the selection or the first available match, -Command-1 through Command-9 launch visible rows, Command-K -focuses search, Command-R refreshes, and Escape clears search before closing. +or projects. Return launches the selection or the first available match in +the default destination, and Command-Return launches it in the other one, as +Command-click does for a click. Command-1 through Command-9 launch visible +rows, Command-K focuses search, Command-R refreshes, and Escape clears search +before closing. Successful launches dismiss the popover. Failed launches leave the error visible. Folder pickers and About dismiss the menu before opening; alerts can remain above it. Dialogs and native menus retain their own keyboard handling. Every row's actions open it once in ChatGPT or Terminal without changing the -default destination. Profile actions add a workspace for that profile and offer +default destination, which the gear menu's Return opens in sets. Profile actions add a workspace for that profile and offer optional Codex CLI sign-in. Workspace actions: pin/unpin, reveal in Finder, copy path, change profile, locate a moved folder, and remove only the binding. Menus disable inapplicable items explicitly, because NSMenu's @@ -100,8 +114,7 @@ unavailable and passed resets never imply a restored quota. The UI retains launch actions during reads and failures. A refresh dims the previous reading instead of replacing it with a placeholder. Readings update their rows in place, so hover, keyboard focus and the VoiceOver cursor survive while they -stream in; only structural changes rebuild the list, and the profile filter is -rebuilt only when profiles change. A 60-second memory cache and two +stream in; only structural changes rebuild the list. A 60-second memory cache and two concurrent readers bound the cost; an explicit refresh bypasses the cache. Replies are framed by newline without rescanning buffered bytes, so the 1 MiB output bound is reached in linear time. @@ -167,9 +180,10 @@ The renderer verifies that the exported backdrop matches the system colour, and both appearances run in the native test suite. Quota numbers are sample values. These are layout previews; they do not demonstrate live translucency, system permission prompts or -upstream app behaviour. The AppKit interaction runner checks profile-first -setup, selection, search, dismissal and launch arguments against an isolated -executable. It switches the same open view light → dark → light, checks +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 +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 preferences. Actual preference toggles, VoiceOver announcements and live @@ -177,7 +191,8 @@ popover glass require a manual macOS check. Quota checks cover the official wire contract, multiple buckets, nullable windows, exhaustion, expired resets, caching, concurrency, malformed replies, timeouts and cancellation. The interaction runner also checks in-place usage -updates, refresh dimming, rows being changed, menu enablement after AppKit +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 diff --git a/macos/CodexProfilesMenu/Previews/RenderPreview.swift b/macos/CodexProfilesMenu/Previews/RenderPreview.swift index bc3ddf4..e9c0083 100644 --- a/macos/CodexProfilesMenu/Previews/RenderPreview.swift +++ b/macos/CodexProfilesMenu/Previews/RenderPreview.swift @@ -23,6 +23,7 @@ struct RenderPreview { ? [] : [ workspace("\(home)/Dev/codex-profiles", profile: "work"), + workspace("\(home)/Dev/billing-api", profile: "work"), workspace("\(home)/Dev/personal-site", profile: "personal"), ] let profiles = CommandLine.arguments.contains("--empty") ? [] : ["default", "personal", "work"] @@ -66,7 +67,8 @@ struct RenderPreview { previewView.needsLayout = true previewView.layoutSubtreeIfNeeded() - let scale = window.backingScaleFactor + // Retina previews, whatever display renders them. + let scale = max(2, window.backingScaleFactor) guard let bitmap = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: Int(size.width * scale), pixelsHigh: Int(size.height * scale), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, diff --git a/macos/CodexProfilesMenu/Previews/menu-dark.png b/macos/CodexProfilesMenu/Previews/menu-dark.png index f26c2ac..29f720f 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 f3cd30c..bd92756 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/AccountUsage.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/AccountUsage.swift index 00836c3..007b859 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/AccountUsage.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/AccountUsage.swift @@ -132,10 +132,10 @@ struct UsageState: Equatable { return nil } - /// The window that blocks first, once it is low enough to mention. - func constrainingWindow(at date: Date) -> RateLimitWindow? { + /// The window that blocks first. A passed reset leaves no current figure. + func tightestWindow(at date: Date) -> RateLimitWindow? { limits?.windows - .filter { !$0.hasReset(at: date) && $0.level != .normal } + .filter { !$0.hasReset(at: date) } .min { lhs, rhs in lhs.remainingPercent != rhs.remainingPercent ? lhs.remainingPercent < rhs.remainingPercent @@ -143,6 +143,11 @@ struct UsageState: Equatable { } } + /// The window that blocks first, once it is low enough to mention. + func constrainingWindow(at date: Date) -> RateLimitWindow? { + tightestWindow(at: date).flatMap { $0.level == .normal ? nil : $0 } + } + /// A compact row summary such as "5h limit low · resets in 1h 12m". func summary(at date: Date = Date()) -> String? { guard let window = constrainingWindow(at: date) else { return nil } diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift index 43d942e..c9e6ff0 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceMenuView.swift @@ -2,12 +2,18 @@ import AppKit @MainActor final class WorkspaceMenuViewController: NSViewController { + static let width: CGFloat = 360 + private static let headerHeight: CGFloat = 44 + private static let footerHeight: CGFloat = 36 + /// Everything that shapes the list except live usage. Usage updates rows in /// place, so streaming readings keep hover, focus and the VoiceOver cursor. private struct ListModel: Equatable { struct Row: Equatable { let item: LaunchTarget let pinned: Bool + /// Listed under its own profile's row, so it needs no profile name. + let nested: Bool let launching: Bool let changing: Bool let enabled: Bool @@ -27,9 +33,7 @@ final class WorkspaceMenuViewController: NSViewController { private let loginItem: LoginItemControlling private let alerts: QuotaAlertController? private let searchField = NSSearchField() - private let profilePicker = NSPopUpButton() - private let destinationPicker = NSSegmentedControl(labels: ["ChatGPT", "Terminal"], trackingMode: .selectOne, target: nil, action: nil) - private let toolbar = NSStackView() + private let titleLabel = NSTextField(labelWithString: "Codex Profiles") private let contentContainer = NSView() private let footerLabel = NSTextField(labelWithString: "") private let refreshButton = NSButton() @@ -38,10 +42,9 @@ final class WorkspaceMenuViewController: NSViewController { private var scrollView: NSScrollView? private var selectedID: LaunchTarget.ID? private var heightConstraint: NSLayoutConstraint? - private var listTopConstraint: NSLayoutConstraint? + private var headerLeadingConstraint: NSLayoutConstraint? private var destination: OpenDestination private var renderedList: ListModel? - private var renderedProfiles: [String]? var onPreferredContentSizeChange: ((NSSize) -> Void)? var onRequestClose: (() -> Void)? @@ -53,7 +56,7 @@ final class WorkspaceMenuViewController: NSViewController { self.alerts = alerts destination = defaults.string(forKey: "openDestination") == OpenDestination.terminal.rawValue ? .terminal : .chatGPT super.init(nibName: nil, bundle: nil) - preferredContentSize = NSSize(width: 400, height: 260) + preferredContentSize = NSSize(width: Self.width, height: 260) } @available(*, unavailable) @@ -64,7 +67,7 @@ final class WorkspaceMenuViewController: NSViewController { surface.translatesAutoresizingMaskIntoConstraints = false view = surface heightConstraint = surface.heightAnchor.constraint(equalToConstant: 260) - NSLayoutConstraint.activate([surface.widthAnchor.constraint(equalToConstant: 400), heightConstraint!]) + NSLayoutConstraint.activate([surface.widthAnchor.constraint(equalToConstant: Self.width), heightConstraint!]) buildInterface() NotificationCenter.default.addObserver(self, selector: #selector(redrawSystemColours), name: NSColor.systemColorsDidChangeNotification, object: nil) @@ -76,26 +79,27 @@ final class WorkspaceMenuViewController: NSViewController { Task { await store.refreshIfNeeded() } } + /// Opening starts unselected; a search left over from last time selects + /// its first match, so Return's target is visible. func focusSearch() { selectedID = nil - for row in rows { row.isSelectedRow = false } + selectFirstMatch(in: store.filteredTargets) + updateSelection() view.window?.makeKey() if !searchField.isHidden { view.window?.makeFirstResponder(searchField) } } func handleShortcut(_ event: NSEvent) -> Bool { let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.numericPad, .function]) + let responder = view.window?.firstResponder + let navigatingList = responder === searchField || responder === searchField.currentEditor() || responder is LaunchRowButton + let isReturn = [36, 76].contains(event.keyCode) if modifiers.isEmpty { - let responder = view.window?.firstResponder - let navigatingList = responder === searchField || responder === searchField.currentEditor() || responder is LaunchRowButton if [125, 126, 36, 76].contains(event.keyCode), !navigatingList { return false } switch event.keyCode { case 125: moveSelection(by: 1); return true case 126: moveSelection(by: -1); return true - case 36, 76: - let targets = store.filteredTargets - if let item = targets.first(where: { $0.id == selectedID }) ?? targets.first(where: store.canLaunch) { launch(item) } - return true + case 36, 76: launchSelection(alternate: false); return true case 53: if !store.query.isEmpty { clearSearch() } else { onRequestClose?() } return true @@ -103,6 +107,12 @@ final class WorkspaceMenuViewController: NSViewController { } } guard modifiers == .command else { return false } + // Command-Return opens the selection in the other destination. + if isReturn { + guard navigatingList else { return false } + launchSelection(alternate: true) + return true + } switch event.charactersIgnoringModifiers?.lowercased() { case "k": focusSearch(); return true case "r": refresh(); return true @@ -121,96 +131,58 @@ final class WorkspaceMenuViewController: NSViewController { } private func buildInterface() { - // The mark and title share the rows' avatar and title columns. - let mark = NSImageView(image: NSImage(systemSymbolName: "square.stack.3d.up.fill", accessibilityDescription: nil) ?? NSImage()) - mark.symbolConfiguration = .init(pointSize: 15, weight: .semibold) - mark.contentTintColor = .labelColor - mark.widthAnchor.constraint(equalToConstant: LaunchRowButton.avatarSize).isActive = true - let title = label("Codex Profiles", size: 13, weight: .semibold) + searchField.placeholderString = "Search profiles and workspaces" + searchField.font = .systemFont(ofSize: 13) + searchField.target = self + searchField.action = #selector(searchChanged) + searchField.sendsSearchStringImmediately = true + searchField.stringValue = store.query + searchField.toolTip = "Search profiles, workspaces and folder paths (⌘K)" + searchField.setAccessibilityLabel("Search profile and workspace names or folder paths") + searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) + // The title stands in for search until there is something to search. + titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) let settings = symbolButton("gearshape", title: "Options", action: #selector(showSettingsMenu(_:))) - let header = NSStackView(views: [mark, title, NSView(), settings]) + let header = NSStackView(views: [searchField, titleLabel, settings]) header.orientation = .horizontal header.alignment = .centerY - header.spacing = LaunchRowButton.avatarSpacing + header.distribution = .fill + header.spacing = 6 header.translatesAutoresizingMaskIntoConstraints = false + searchField.heightAnchor.constraint(equalToConstant: 28).isActive = true let listDivider = NSBox() listDivider.boxType = .separator listDivider.translatesAutoresizingMaskIntoConstraints = false - searchField.placeholderString = "Search profiles and workspaces" - searchField.font = .systemFont(ofSize: 12) - searchField.controlSize = .small - searchField.target = self - searchField.action = #selector(searchChanged) - searchField.sendsSearchStringImmediately = true - searchField.stringValue = store.query - searchField.setAccessibilityLabel("Search profile and workspace names or folder paths") - profilePicker.target = self - profilePicker.action = #selector(profileFilterChanged) - profilePicker.controlSize = .small - profilePicker.font = .systemFont(ofSize: 11) - profilePicker.setAccessibilityLabel("Filter profiles and workspaces") - profilePicker.widthAnchor.constraint(equalToConstant: 140).isActive = true - destinationPicker.selectedSegment = destination == .chatGPT ? 0 : 1 - destinationPicker.segmentStyle = .rounded - destinationPicker.controlSize = .small - destinationPicker.target = self - destinationPicker.action = #selector(destinationChanged) - destinationPicker.font = .systemFont(ofSize: 11) - destinationPicker.setAccessibilityLabel("Open in") - destinationPicker.widthAnchor.constraint(equalToConstant: 190).isActive = true - toolbar.orientation = .horizontal - toolbar.alignment = .centerY - toolbar.spacing = 8 - [profilePicker, NSView(), destinationPicker].forEach { toolbar.addArrangedSubview($0) } - let controls = NSStackView(views: [searchField, toolbar]) - controls.orientation = .vertical - controls.alignment = .width - controls.spacing = 8 - controls.translatesAutoresizingMaskIntoConstraints = false - searchField.heightAnchor.constraint(equalToConstant: 24).isActive = true - toolbar.heightAnchor.constraint(equalToConstant: 24).isActive = true - let divider = NSBox() divider.boxType = .separator divider.translatesAutoresizingMaskIntoConstraints = false - let add = addButton - add.title = "Add workspace…" - add.target = self - add.action = #selector(addWorkspace(_:)) - add.image = NSImage(systemSymbolName: "plus", accessibilityDescription: nil) - add.symbolConfiguration = .init(pointSize: 11, weight: .medium) - add.imagePosition = .imageLeading - add.isBordered = false - add.font = .systemFont(ofSize: 12) - add.contentTintColor = .secondaryLabelColor - add.toolTip = "Optional: bind a project folder to a profile" - add.setContentHuggingPriority(.required, for: .horizontal) + configureSymbolButton(addButton, symbol: "plus", title: "Add a workspace", action: #selector(addWorkspace(_:))) footerLabel.font = .systemFont(ofSize: 11) footerLabel.textColor = .secondaryLabelColor footerLabel.lineBreakMode = .byTruncatingTail footerLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) footerLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) configureSymbolButton(refreshButton, symbol: "arrow.clockwise", title: "Refresh profiles, workspaces and Codex usage (⌘R)", action: #selector(refresh)) - let footer = NSStackView(views: [add, NSView(), footerLabel, refreshButton]) + let footer = NSStackView(views: [footerLabel, addButton, refreshButton]) footer.orientation = .horizontal footer.alignment = .centerY - footer.spacing = 8 + footer.distribution = .fill + footer.spacing = 2 + footer.setCustomSpacing(8, after: footerLabel) footer.translatesAutoresizingMaskIntoConstraints = false contentContainer.translatesAutoresizingMaskIntoConstraints = false - // Header, search and filters form one block above a single rule, so - // scrolled rows pass under a clear edge. - [header, controls, listDivider, contentContainer, divider, footer].forEach { view.addSubview($0) } - listTopConstraint = listDivider.topAnchor.constraint(equalTo: controls.bottomAnchor, constant: 10) + // Search and options form one line above a single rule, so scrolled + // rows pass under a clear edge. + [header, listDivider, contentContainer, divider, footer].forEach { view.addSubview($0) } + headerLeadingConstraint = header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10) NSLayoutConstraint.activate([ header.topAnchor.constraint(equalTo: view.topAnchor), - header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: LaunchRowButton.contentInset), - header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), - header.heightAnchor.constraint(equalToConstant: 44), - controls.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 2), - controls.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: LaunchRowButton.contentInset), - controls.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -LaunchRowButton.contentInset), - listTopConstraint!, + headerLeadingConstraint!, + header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + header.heightAnchor.constraint(equalToConstant: Self.headerHeight), + listDivider.topAnchor.constraint(equalTo: header.bottomAnchor), listDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor), listDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentContainer.topAnchor.constraint(equalTo: listDivider.bottomAnchor), contentContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -219,8 +191,8 @@ final class WorkspaceMenuViewController: NSViewController { divider.leadingAnchor.constraint(equalTo: view.leadingAnchor), divider.trailingAnchor.constraint(equalTo: view.trailingAnchor), divider.bottomAnchor.constraint(equalTo: footer.topAnchor), footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: LaunchRowButton.contentInset), - footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), - footer.bottomAnchor.constraint(equalTo: view.bottomAnchor), footer.heightAnchor.constraint(equalToConstant: 36), + footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + footer.bottomAnchor.constraint(equalTo: view.bottomAnchor), footer.heightAnchor.constraint(equalToConstant: Self.footerHeight), ]) } @@ -229,15 +201,14 @@ final class WorkspaceMenuViewController: NSViewController { let populated = store.phase == .ready && (!store.profiles.isEmpty || !store.workspaces.isEmpty) let shouldFocusSearch = populated && searchField.isHidden searchField.isHidden = !populated - toolbar.isHidden = !populated - // Without filters, the rule meets the header directly. - listTopConstraint?.constant = populated ? 10 : -2 + titleLabel.isHidden = populated + // 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 - footerLabel.stringValue = ["Choose a profile or workspace", "Create a profile to get started"].contains(store.statusMessage) ? "" : store.statusMessage - footerLabel.toolTip = footerLabel.stringValue + 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 - updateProfilePicker() + selectFirstMatch(in: targets) let list = store.phase == .ready && !targets.isEmpty ? listModel(for: targets) : nil if let list, list == renderedList, scrollView != nil { @@ -245,6 +216,7 @@ final class WorkspaceMenuViewController: NSViewController { row.isSelectedRow = row.item.id == selectedID if row.item.workspace == nil { row.update(usage: store.usageState(for: row.item.profile)) } } + updateFooter() return } renderedList = list @@ -260,18 +232,22 @@ final class WorkspaceMenuViewController: NSViewController { case .ready where store.profiles.isEmpty && store.workspaces.isEmpty: showState(symbol: "person.crop.circle.badge.plus", title: "Create your first profile", detail: "Open separate ChatGPT windows for work and personal use.", actionTitle: "Create profile…", action: #selector(createProfile)) case .ready where targets.isEmpty: - showState(symbol: "magnifyingglass", title: "No matches", detail: "Try another profile or project name.", actionTitle: "Reset filters", action: #selector(resetFilters)) + showState(symbol: "magnifyingglass", title: "No matches", detail: "Try another profile or project name.", actionTitle: "Clear search", action: #selector(clearSearch)) case .ready: if let list { showLaunchList(list) } } - let contentHeight = scrollView?.documentView?.fittingSize.height - ?? contentContainer.subviews.first.map { $0.fittingSize.height + 32 } ?? 100 - // Header, filters, separators and footer occupy 150 points. - let height: CGFloat = populated ? min(500, max(260, 150 + contentHeight)) : 260 + 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) + } else { + min(500, max(260, chrome + (contentContainer.subviews.first.map { $0.fittingSize.height + 48 } ?? 100))) + } heightConstraint?.constant = height - preferredContentSize = NSSize(width: 400, height: height) + preferredContentSize = NSSize(width: Self.width, height: height) onPreferredContentSizeChange?(preferredContentSize) view.layoutSubtreeIfNeeded() + updateFooter() if shouldFocusSearch, view.window?.isKeyWindow == true { focusSearch() } if let scrollView { let maxY = max(0, (scrollView.documentView?.bounds.height ?? 0) - scrollView.contentView.bounds.height) @@ -281,10 +257,12 @@ final class WorkspaceMenuViewController: NSViewController { } private func listModel(for targets: [LaunchTarget]) -> ListModel { - ListModel(destination: destination, rows: targets.map { item in + let listedProfiles = Set(targets.filter { $0.workspace == nil }.map(\.profile)) + return ListModel(destination: destination, rows: targets.map { item in ListModel.Row( item: item, pinned: item.workspace.map(store.isPinned) ?? false, + nested: item.workspace.map { listedProfiles.contains($0.profile) } ?? false, launching: store.launchingID == item.id, changing: store.isChanging(item), enabled: store.canLaunch(item) && store.launchingID == nil @@ -292,17 +270,34 @@ final class WorkspaceMenuViewController: NSViewController { }) } - /// Rebuilds the filter only when profiles change, so an open filter menu - /// is not replaced while usage readings arrive. - private func updateProfilePicker() { - if renderedProfiles != store.profiles { - profilePicker.removeAllItems() - profilePicker.addItem(withTitle: "All profiles") - for profile in store.profiles { profilePicker.addItem(withTitle: profile) } - renderedProfiles = store.profiles + private var isSearching: Bool { !store.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + /// While searching, the first available match is selected so Return's + /// target is visible before it is pressed. + private func selectFirstMatch(in targets: [LaunchTarget]) { + guard selectedID == nil, isSearching else { return } + selectedID = targets.first(where: store.canLaunch)?.id + } + + private func updateSelection() { + for row in rows { row.isSelectedRow = row.item.id == selectedID } + updateFooter() + } + + /// The footer says what Return and Command-Return do until the store has + /// news to report, such as a launch or a refresh failure. + private func updateFooter() { + let other = "⌘↩ \(destination.alternate.label)" + 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)" + } else if !rows.isEmpty { + footerLabel.stringValue = "↩ Open in \(destination.label) · \(other)" + } else { + footerLabel.stringValue = "" } - let index = store.selectedProfile.flatMap { store.profiles.firstIndex(of: $0) }.map { $0 + 1 } ?? 0 - if profilePicker.indexOfSelectedItem != index { profilePicker.selectItem(at: index) } + footerLabel.toolTip = footerLabel.stringValue } private func showLaunchList(_ list: ListModel) { @@ -317,59 +312,36 @@ final class WorkspaceMenuViewController: NSViewController { stack.orientation = .vertical stack.alignment = .width stack.spacing = 0 - stack.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 6, right: 0) + stack.edgeInsets = NSEdgeInsets(top: 6, left: 0, bottom: 6, right: 0) stack.translatesAutoresizingMaskIntoConstraints = false document.addSubview(stack) - func section(of row: ListModel.Row) -> String { - row.item.workspace == nil ? "Profiles" : row.pinned ? "Pinned workspaces" : "Workspaces" + // A profile and the workspaces nested under it form one group; search + // results without their profile row form another. + func group(of row: ListModel.Row) -> String? { + row.item.workspace == nil || row.nested ? row.item.profile : nil } - var previousGroup: String? for (index, model) in list.rows.enumerated() { let item = model.item - let groupTitle = section(of: model) - if previousGroup != groupTitle { - let heading = label(groupTitle, size: 11, weight: .semibold) - heading.textColor = .secondaryLabelColor - let group = NSStackView(views: [heading, NSView()]) - group.spacing = 0 - // Space above a heading separates groups; rules only divide rows within one. - let first = previousGroup == nil - group.edgeInsets = NSEdgeInsets(top: first ? 8 : 12, left: LaunchRowButton.contentInset, bottom: 4, right: 0) - if item.workspace == nil { - let usageHeading = label("Codex left", size: 11, weight: .semibold) - usageHeading.textColor = .secondaryLabelColor - usageHeading.alignment = .right - usageHeading.toolTip = "Remaining Codex CLI quota. ChatGPT may use a different account." - usageHeading.widthAnchor.constraint(equalToConstant: ProfileUsageView.columnWidth).isActive = true - group.addArrangedSubview(usageHeading) - let actionsSpace = NSView() - actionsSpace.widthAnchor.constraint(equalToConstant: LaunchRowButton.actionsWidth).isActive = true - group.addArrangedSubview(actionsSpace) - } - group.heightAnchor.constraint(equalToConstant: first ? 28 : 32).isActive = true - stack.addArrangedSubview(group) - group.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true - previousGroup = groupTitle + if index > 0, group(of: model) != group(of: list.rows[index - 1]) { + let divider = GroupDivider() + stack.addArrangedSubview(divider) + divider.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } - let row = LaunchRowButton(item: item, shortcutIndex: index, destination: list.destination, - usage: item.workspace == nil ? store.usageState(for: item.profile) : nil, pinned: model.pinned, + let row = LaunchRowButton(item: item, 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(_:))) row.onShowActions = { [weak self] sender in guard let self else { return } self.actionsMenu(for: item).popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.height), in: sender) } + row.contextMenu = { [weak self] in self?.actionsMenu(for: item) } + row.onLocate = { [weak self] in + if let workspace = item.workspace { self?.chooseFolder(for: workspace.profile, replacing: workspace) } + } rows.append(row) stack.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true - if let rule = rows.dropLast().last?.ruleBelow { rule.below = row; row.ruleAbove = rule; rule.update() } - if index < list.rows.count - 1, section(of: list.rows[index + 1]) == groupTitle { - let rule = RowRule() - rule.above = row - row.ruleBelow = rule - stack.addArrangedSubview(rule) - rule.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true - } } scroll.documentView = document scrollView = scroll @@ -389,10 +361,17 @@ final class WorkspaceMenuViewController: NSViewController { guard !available.isEmpty else { return } let current = available.firstIndex { $0.id == selectedID } ?? (offset > 0 ? -1 : available.count) selectedID = available[min(available.count - 1, max(0, current + offset))].id - for row in rows { row.isSelectedRow = row.item.id == selectedID } + updateSelection() if let row = rows.first(where: { $0.item.id == selectedID }) { row.scrollToVisible(row.bounds) } } + /// Return opens the selection, or the first available row without one. + private func launchSelection(alternate: Bool) { + let targets = store.filteredTargets + guard let item = targets.first(where: { $0.id == selectedID }) ?? targets.first(where: store.canLaunch) else { return } + launch(item, in: alternate ? destination.alternate : nil) + } + /// The store explains refusals, such as a missing folder, in the footer. private func launch(_ item: LaunchTarget, in requested: OpenDestination? = nil) { guard store.launchingID == nil else { return } @@ -410,7 +389,7 @@ final class WorkspaceMenuViewController: NSViewController { copy.textColor = .secondaryLabelColor copy.alignment = .center copy.maximumNumberOfLines = 4 - copy.widthAnchor.constraint(lessThanOrEqualToConstant: 300).isActive = true + copy.widthAnchor.constraint(lessThanOrEqualToConstant: 280).isActive = true let stack = NSStackView(views: [icon, heading, copy]) stack.orientation = .vertical stack.alignment = .centerX @@ -439,6 +418,10 @@ final class WorkspaceMenuViewController: NSViewController { let menu = NSMenu() menu.autoenablesItems = false let idle = store.launchingID == nil + if item.workspace == nil { + for line in usageItems(for: store.usageState(for: item.profile)) { menu.addItem(line) } + menu.addItem(.separator()) + } for destination in [OpenDestination.chatGPT, .terminal] { let open = menuItem("Open in \(destination.label)", #selector(openFromMenu(_:)), symbol: destination.symbolName, LaunchRequest(item: item, destination: destination)) @@ -482,6 +465,31 @@ final class WorkspaceMenuViewController: NSViewController { return menu } + /// Every window with its reset time, which the row's single figure leaves + /// out. The lines are information, so they stay disabled. + private func usageItems(for usage: UsageState, now: Date = Date()) -> [NSMenuItem] { + var lines = ["Codex CLI usage"] + if case let .available(limits, checkedAt) = usage.reading { + lines += limits.windows.enumerated().map { index, window in + let name = window.durationLabel ?? "Limit \(index + 1)" + let clock = window.resetsAt.map { UsageFormat.clock(Date(timeIntervalSince1970: $0), relativeTo: now) } + // A passed reset keeps its time, worded in the past like the tooltip. + guard !window.hasReset(at: now) else { return "\(name): reset at \(clock ?? "an unknown time") · awaiting a fresh reading" } + return "\(name): \(window.remainingPercent)% left" + (clock.map { " · resets \($0)" } ?? "") + } + lines.append((usage.isRefreshing ? "Refreshing… " : "") + "Checked \(UsageFormat.clock(checkedAt, relativeTo: now))") + } else if usage.reading == nil || usage.isRefreshing { + lines.append("Checking…") + } else { + lines.append("Unavailable · sign in to Codex CLI, then refresh") + } + return lines.map { line in + let item = NSMenuItem(title: line, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + } + func settingsMenu() -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false @@ -493,6 +501,17 @@ final class WorkspaceMenuViewController: NSViewController { login.isEnabled = !store.profiles.isEmpty menu.addItem(login) menu.addItem(.separator()) + // Command-Return and Command-click open the other one. + let returnOpens = NSMenuItem(title: "Return opens in", action: nil, keyEquivalent: "") + let destinations = NSMenu() + destinations.autoenablesItems = false + for choice in [OpenDestination.chatGPT, .terminal] { + let option = menuItem(choice.label, #selector(destinationChosen(_:)), symbol: nil, choice.rawValue) + option.state = choice == destination ? .on : .off + destinations.addItem(option) + } + returnOpens.submenu = destinations + menu.addItem(returnOpens) let openAtLogin = menuItem("Open at Login", #selector(toggleOpenAtLogin), symbol: nil, nil) switch loginItem.status { case .enabled: openAtLogin.state = .on @@ -566,17 +585,20 @@ final class WorkspaceMenuViewController: NSViewController { return button } - @objc private func launchClicked(_ sender: LaunchRowButton) { launch(sender.item) } + /// Clicking opens in the default destination; Command-click opens in the other. + @objc private func launchClicked(_ sender: LaunchRowButton) { + let alternate = NSApp.currentEvent?.modifierFlags.contains(.command) == true + launch(sender.item, in: alternate ? destination.alternate : nil) + } @objc private func openFromMenu(_ sender: NSMenuItem) { guard let request = sender.representedObject as? LaunchRequest else { return } launch(request.item, in: request.destination) } @objc private func refresh() { Task { await store.refresh(); await store.refreshUsage(force: true) } } @objc private func clearSearch() { selectedID = nil; searchField.stringValue = ""; store.query = ""; focusSearch() } - @objc private func resetFilters() { store.selectedProfile = nil; clearSearch() } - @objc private func profileFilterChanged() { selectedID = nil; store.selectedProfile = profilePicker.indexOfSelectedItem == 0 ? nil : profilePicker.titleOfSelectedItem } - @objc private func destinationChanged() { - destination = destinationPicker.selectedSegment == 1 ? .terminal : .chatGPT + @objc private func destinationChosen(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String, let choice = OpenDestination(rawValue: value) else { return } + destination = choice defaults.set(destination.rawValue, forKey: "openDestination") render() } @@ -603,8 +625,11 @@ final class WorkspaceMenuViewController: NSViewController { } @objc private func addWorkspace(_ sender: NSButton) { guard !store.profiles.isEmpty else { promptForProfile(addingWorkspace: true); return } - if let profile = store.selectedProfile ?? (store.profiles.count == 1 ? store.profiles.first : nil) { chooseFolder(for: profile); return } + if store.profiles.count == 1, let profile = store.profiles.first { chooseFolder(for: profile); return } let menu = NSMenu(title: "Choose profile") + let heading = NSMenuItem(title: "Add a workspace for", action: nil, keyEquivalent: "") + heading.isEnabled = false + menu.addItem(heading) for profile in store.profiles { let item = NSMenuItem(title: profile, action: #selector(profileChosen(_:)), keyEquivalent: "") item.target = self @@ -705,7 +730,6 @@ final class WorkspaceMenuViewController: NSViewController { Task { if await store.createProfile(name) { clearSearch() - store.selectedProfile = name if addingWorkspace { chooseFolder(for: name) } await store.refreshUsage() } @@ -732,48 +756,57 @@ final class WorkspaceMenuViewController: NSViewController { final class FlippedView: NSView { override var isFlipped: Bool { true } } +/// One launchable row. A profile row is 46 points with its quota beside the +/// name; a workspace is a single 30-point line nested under its profile, and +/// grows to two lines only to explain why it cannot open. final class LaunchRowButton: NSButton { - /// Column metrics shared with the header, section headings and rules. + /// Column metrics shared with the header, dividers and nested workspaces. static let contentInset: CGFloat = 14 - static let avatarSize: CGFloat = 22 + static let avatarSize: CGFloat = 26 static let avatarSpacing: CGFloat = 10 + /// Where profile names start. Nested workspace symbols start here too, so + /// each workspace reads as part of the profile above it. static let titleInset = contentInset + avatarSize + avatarSpacing - private static let openWidth: CGFloat = 54 - private static let moreWidth: CGFloat = 20 - private static let usageSpacing: CGFloat = 12 - private static let moreSpacing: CGFloat = 4 - /// From the quota column's trailing edge to the row's trailing edge. - static let actionsWidth = usageSpacing + openWidth + moreSpacing + moreWidth + contentInset + static let symbolSize: CGFloat = 16 + private static let moreWidth: CGFloat = 22 let item: LaunchTarget var onShowActions: ((NSButton) -> Void)? + /// Right-click and Control-click show the same actions as the ⋯ button. + var contextMenu: (() -> NSMenu?)? + var onLocate: (() -> Void)? var isSelectedRow: Bool { didSet { setAccessibilitySelected(isSelectedRow); highlightChanged() } } /// The inset, rounded plate behind hover and keyboard selection. let highlight = RowHighlightView() - /// Rules beside this row, which step aside while its plate shows. - weak var ruleAbove: RowRule? - weak var ruleBelow: RowRule? var showsPlate: Bool { isSelectedRow || isHovered && isEnabled } private var isHovered = false { didSet { highlightChanged() } } private var tracking: NSTrackingArea? private let destination: OpenDestination - private let metadata: NSTextField + private let launching: Bool + private let detail: NSTextField private let profileDetail: String private let usageView: ProfileUsageView? - private let avatar: RowAvatar + private let avatar: RowAvatar? + private let more: NSButton - init(item: LaunchTarget, shortcutIndex: Int, destination: OpenDestination, usage: UsageState?, pinned: Bool, selected: Bool, + init(item: LaunchTarget, 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 + self.launching = launching isSelectedRow = selected - profileDetail = destination == .terminal ? "Codex CLI profile" : item.profile == "default" ? "Standard ChatGPT window" : "Separate ChatGPT window" - metadata = NSTextField(labelWithString: item.workspace.map { "\($0.profile) · \($0.displayPath())" } ?? profileDetail) - usageView = item.workspace == nil ? ProfileUsageView(usage: usage ?? UsageState()) : nil - // A monogram gives each profile its own mark; workspaces share a folder tile. - avatar = item.workspace.map { workspace in - RowAvatar(symbol: !workspace.isAvailable ? "questionmark.folder" : pinned ? "pin.fill" : "folder") - } ?? RowAvatar(monogram: item.profile) + let workspace = item.workspace + profileDetail = launching ? "Opening in \(destination.label)…" + : destination == .terminal ? "Codex CLI profile" : item.profile == "default" ? "Standard ChatGPT window" : "Separate ChatGPT window" + let workspaceDetail = workspace.map { workspace in + changing ? "Updating…" : launching ? "Opening in \(destination.label)…" + : workspace.availabilityReason.map { "\($0) · \(workspace.displayPath())" } ?? workspace.displayPath() + } + 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 + more = NSButton(image: NSImage(systemSymbolName: "ellipsis", accessibilityDescription: "Actions for \(item.name)") ?? NSImage(), target: nil, action: nil) super.init(frame: .zero) self.target = target self.action = action @@ -783,86 +816,101 @@ final class LaunchRowButton: NSButton { focusRingType = .none isEnabled = enabled translatesAutoresizingMaskIntoConstraints = false - heightAnchor.constraint(equalToConstant: item.isAvailable ? 48 : 64).isActive = true + let twoLines = workspace == nil || !item.isAvailable + heightAnchor.constraint(equalToConstant: workspace == nil ? 46 : twoLines ? 44 : 30).isActive = true let state = changing ? "Updating" : "Open in \(destination.label)" - setAccessibilityLabel(item.workspace.map { "\($0.name), profile \($0.profile), \($0.path). \($0.availabilityReason ?? state)" } + setAccessibilityLabel(workspace.map { "\($0.name), profile \($0.profile), \($0.path). \($0.availabilityReason ?? state)" } ?? "Profile \(item.profile). \(state)") setAccessibilitySelected(selected) - toolTip = item.workspace.map { "\($0.path)\nProfile: \($0.profile)" } ?? "Open profile \(item.profile) without a project folder" + toolTip = workspace.map { "\($0.path)\nProfile: \($0.profile)" } ?? "Open profile \(item.profile) without a project folder" + let name = NSTextField(labelWithString: item.name) - name.font = .systemFont(ofSize: 13, weight: .medium) + name.font = .systemFont(ofSize: 13, weight: workspace == nil ? .semibold : .regular) name.lineBreakMode = .byTruncatingTail name.toolTip = item.name name.textColor = item.isAvailable ? .labelColor : .secondaryLabelColor - metadata.font = .systemFont(ofSize: 11) - metadata.textColor = .secondaryLabelColor + detail.font = .systemFont(ofSize: 11) + detail.textColor = .secondaryLabelColor // Paths keep both ends readable; prose keeps its beginning. - metadata.lineBreakMode = item.workspace == nil ? .byTruncatingTail : .byTruncatingMiddle - if let workspace = item.workspace { - let metadataText = NSMutableAttributedString(attributedString: metadata.attributedStringValue) - metadataText.addAttribute(.font, value: NSFont.systemFont(ofSize: 11, weight: .medium), - range: NSRange(location: 0, length: (workspace.profile as NSString).length)) - metadata.attributedStringValue = metadataText - } - metadata.toolTip = toolTip - let labels = NSStackView(views: [name, metadata]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 2 - for value in [name, metadata] { - value.widthAnchor.constraint(equalTo: labels.widthAnchor).isActive = true - value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + detail.lineBreakMode = workspace == nil || changing || launching ? .byTruncatingTail : .byTruncatingMiddle + detail.toolTip = toolTip + name.setContentCompressionResistancePriority(.init(260), for: .horizontal) + detail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let labels = NSStackView(views: [name, detail]) + labels.orientation = twoLines ? .vertical : .horizontal + labels.alignment = twoLines ? .leading : .firstBaseline + labels.spacing = twoLines ? 1 : 8 + if twoLines { + for value in [name, detail] { value.widthAnchor.constraint(lessThanOrEqualTo: labels.widthAnchor).isActive = true } } labels.setContentHuggingPriority(.defaultLow, for: .horizontal) labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - if let reason = item.workspace?.availabilityReason { - let warning = NSTextField(labelWithString: reason) - warning.font = .systemFont(ofSize: 11) - warning.textColor = .secondaryLabelColor - warning.lineBreakMode = .byTruncatingTail - labels.addArrangedSubview(warning) - warning.widthAnchor.constraint(equalTo: labels.widthAnchor).isActive = true - warning.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + var rowViews: [NSView] = [] + if let avatar { rowViews.append(avatar) } + if let workspace { + let symbol = NSImageView(image: NSImage(systemSymbolName: !workspace.isAvailable ? "questionmark.folder" : pinned ? "pin" : "folder", + accessibilityDescription: nil) ?? NSImage()) + symbol.symbolConfiguration = .init(pointSize: 12, weight: .regular) + symbol.contentTintColor = .secondaryLabelColor + symbol.widthAnchor.constraint(equalToConstant: Self.symbolSize).isActive = true + symbol.setContentHuggingPriority(.required, for: .horizontal) + rowViews.append(symbol) } - let openTitle = launching ? "Opening…" : changing ? "Updating…" : "Open" - let open = NSButton(title: openTitle, target: self, action: #selector(openTarget(_:))) - open.bezelStyle = .rounded - open.controlSize = .small - open.font = .systemFont(ofSize: 11) - open.contentTintColor = .labelColor - open.isEnabled = enabled - open.widthAnchor.constraint(equalToConstant: openTitle == "Open" ? Self.openWidth : 78).isActive = true - open.toolTip = "Open in \(destination.label)" + (shortcutIndex < 9 ? " (⌘\(shortcutIndex + 1))" : "") - open.setAccessibilityLabel("Open \(item.name) in \(destination.label)") - let more = NSButton(image: NSImage(systemSymbolName: "ellipsis", accessibilityDescription: "Actions for \(item.name)") ?? NSImage(), target: self, action: #selector(showActions(_:))) + rowViews.append(labels) + if let workspace, !nested { + // Without its profile's row above, a workspace names its profile. + let tag = NSTextField(labelWithString: workspace.profile) + tag.font = .systemFont(ofSize: 11, weight: .medium) + tag.textColor = .secondaryLabelColor + tag.lineBreakMode = .byTruncatingTail + tag.setContentHuggingPriority(.required, for: .horizontal) + tag.setContentCompressionResistancePriority(.init(255), for: .horizontal) + rowViews.append(tag) + } + if let usageView { rowViews.append(usageView) } + if let workspace, !workspace.pathExists { + let locate = NSButton(title: "Locate…", target: self, action: #selector(locateFolder(_:))) + locate.bezelStyle = .rounded + locate.controlSize = .small + locate.font = .systemFont(ofSize: 11) + locate.isEnabled = !changing + locate.toolTip = "Choose where \(workspace.name) moved" + locate.setContentHuggingPriority(.required, for: .horizontal) + rowViews.append(locate) + } + more.target = self + more.action = #selector(showActions(_:)) more.isBordered = false more.contentTintColor = .secondaryLabelColor - more.toolTip = item.workspace == nil ? "Open elsewhere, add a workspace, or sign in to Codex CLI" : "Open elsewhere, pin, change profile, or remove binding" + more.toolTip = workspace == nil ? "Open elsewhere, add a workspace, or sign in to Codex CLI" : "Open elsewhere, pin, change profile, or remove binding" more.isEnabled = !changing more.widthAnchor.constraint(equalToConstant: Self.moreWidth).isActive = true - more.heightAnchor.constraint(equalToConstant: 24).isActive = true - var rowViews: [NSView] = [avatar, labels] - if let usageView { rowViews.append(usageView) } - rowViews += [open, more] + more.heightAnchor.constraint(equalToConstant: 22).isActive = true + rowViews.append(more) + let row = NSStackView(views: rowViews) row.orientation = .horizontal row.alignment = .centerY row.distribution = .fill row.spacing = 8 - row.setCustomSpacing(Self.avatarSpacing, after: avatar) - if let usageView { row.setCustomSpacing(Self.usageSpacing, after: usageView) } - row.setCustomSpacing(Self.moreSpacing, after: open) + if let avatar { row.setCustomSpacing(Self.avatarSpacing, after: avatar) } + if let usageView { row.setCustomSpacing(4, after: usageView) } row.translatesAutoresizingMaskIntoConstraints = false addSubview(highlight) addSubview(row) + // Nested workspaces start at the profile's title column; others centre + // their symbol under the profile tiles. + let leading = workspace == nil ? Self.contentInset : nested ? Self.titleInset : Self.contentInset + (Self.avatarSize - Self.symbolSize) / 2 NSLayoutConstraint.activate([ highlight.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), highlight.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), - highlight.topAnchor.constraint(equalTo: topAnchor, constant: 2), highlight.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2), - row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.contentInset), - row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.contentInset), + highlight.topAnchor.constraint(equalTo: topAnchor, constant: 1), highlight.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), + row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leading), + row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), row.centerYAnchor.constraint(equalTo: centerYAnchor), ]) - if item.workspace == nil { update(usage: usage ?? UsageState()) } + if workspace == nil { update(usage: usage ?? UsageState()) } + updateActionsVisibility() } @available(*, unavailable) @@ -872,12 +920,12 @@ final class LaunchRowButton: NSButton { func update(usage: UsageState, now: Date = Date()) { guard let usageView else { return } usageView.update(usage, now: now) - let detail = usage.detail(at: now) - toolTip = "Open profile \(item.profile) without a project folder\n\(detail)" - metadata.toolTip = toolTip + let explanation = usage.detail(at: now) + toolTip = "Open profile \(item.profile) without a project folder\n\(explanation)" + detail.toolTip = toolTip // A low quota matters more than the destination description. - metadata.stringValue = usage.summary(at: now) ?? profileDetail - setAccessibilityLabel("Profile \(item.profile). Open in \(destination.label). \(detail)") + if !launching { detail.stringValue = usage.summary(at: now) ?? profileDetail } + setAccessibilityLabel("Profile \(item.profile). Open in \(destination.label). \(explanation)") } override var wantsUpdateLayer: Bool { true } @@ -900,7 +948,7 @@ final class LaunchRowButton: NSButton { } func redrawUsageMeters() { usageView?.redrawMeters() } - func redrawAvatar() { avatar.needsDisplay = true } + func redrawAvatar() { avatar?.needsDisplay = true } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } override func updateTrackingAreas() { super.updateTrackingAreas() @@ -910,24 +958,43 @@ final class LaunchRowButton: NSButton { } override func mouseEntered(with event: NSEvent) { isHovered = true } override func mouseExited(with event: NSEvent) { isHovered = false } + override func menu(for event: NSEvent) -> NSMenu? { contextMenu?() ?? super.menu(for: event) } private func highlightChanged() { needsDisplay = true - ruleAbove?.update() - ruleBelow?.update() + updateActionsVisibility() } - @objc private func openTarget(_ sender: NSButton) { performClick(nil) } + /// The actions button appears with the plate, so resting rows stay quiet. + /// It stays in the accessibility tree, and rows that need repair always show it. + private func updateActionsVisibility() { more.alphaValue = showsPlate || !item.isAvailable ? 1 : 0 } @objc private func showActions(_ sender: NSButton) { onShowActions?(sender) } + @objc private func locateFolder(_ sender: NSButton) { onLocate?() } } +/// A profile's tightest Codex window: its remaining percentage and duration +/// beside a ring. The number carries the value; colour adds emphasis when +/// the window is low. final class ProfileUsageView: NSStackView { - static let columnWidth: CGFloat = 96 + static let figureWidth: CGFloat = 64 + private let figure = NSTextField(labelWithString: "") + private let caption = NSTextField(labelWithString: "") + private let ring = QuotaRing() init(usage: UsageState, now: Date = Date()) { super.init(frame: .zero) - orientation = .vertical - alignment = .width - spacing = 3 - widthAnchor.constraint(equalToConstant: Self.columnWidth).isActive = true + orientation = .horizontal + alignment = .centerY + spacing = 8 + caption.font = .systemFont(ofSize: 11) + caption.textColor = .secondaryLabelColor + caption.alignment = .right + let labels = NSStackView(views: [figure, caption]) + labels.orientation = .vertical + labels.alignment = .trailing + labels.spacing = 1 + labels.widthAnchor.constraint(equalToConstant: Self.figureWidth).isActive = true + addArrangedSubview(labels) + addArrangedSubview(ring) + setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) setAccessibilityLabel("Codex quota remaining") update(usage, now: now) @@ -936,84 +1003,93 @@ final class ProfileUsageView: NSStackView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Replaces the readings in place. A refresh dims the previous reading - /// rather than hiding it; expired windows show a dash, never restored quota. + /// Replaces the reading in place. A refresh dims the previous reading + /// rather than hiding it; a passed reset shows a dash, never restored quota. func update(_ usage: UsageState, now: Date = Date()) { - arrangedSubviews.forEach { $0.removeFromSuperview() } toolTip = usage.detail(at: now) setAccessibilityValue(toolTip) - guard let limits = usage.limits else { - let checking = usage.reading == nil || usage.isRefreshing - let message = NSTextField(labelWithString: checking ? "Checking…" : "Unavailable") - message.font = .systemFont(ofSize: 11) - message.textColor = .secondaryLabelColor - message.alignment = .right - addArrangedSubview(message) - message.widthAnchor.constraint(equalTo: widthAnchor).isActive = true - return - } - for (index, window) in limits.windows.enumerated() { - let expired = window.hasReset(at: now) - let duration = NSTextField(labelWithString: window.durationLabel ?? "Limit \(index + 1)") - duration.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular) - duration.textColor = .secondaryLabelColor - duration.alignment = .right - duration.lineBreakMode = .byTruncatingTail - duration.setContentHuggingPriority(.defaultLow, for: .horizontal) - duration.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let meter = QuotaMeter() - meter.fraction = expired ? 0 : CGFloat(window.remainingPercent) / 100 - meter.level = expired ? .normal : window.level - let remaining = NSTextField(labelWithString: "") - remaining.attributedStringValue = expired - ? Self.figure("—", unit: "", colour: .secondaryLabelColor) - : Self.figure("\(window.remainingPercent)", unit: "%", colour: window.level == .critical ? .systemRed : .labelColor) - remaining.widthAnchor.constraint(equalToConstant: 28).isActive = true - let line = NSStackView(views: [duration, meter, remaining]) - line.distribution = .fill - line.alignment = .centerY - line.spacing = 5 - line.alphaValue = usage.isRefreshing ? 0.45 : 1 - addArrangedSubview(line) - line.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + for view in arrangedSubviews { view.alphaValue = usage.isRefreshing && usage.limits != nil ? 0.45 : 1 } + if let window = usage.tightestWindow(at: now) { + figure.attributedStringValue = Self.figure("\(window.remainingPercent)", unit: "%", level: window.level) + caption.stringValue = "\(window.durationLabel ?? "Limit") left" + ring.fraction = CGFloat(window.remainingPercent) / 100 + ring.level = window.level + } else { + figure.attributedStringValue = Self.figure("—", unit: "", level: nil) + caption.stringValue = usage.limits != nil ? "Reset" : usage.reading == nil || usage.isRefreshing ? "Checking…" : "Unavailable" + ring.fraction = nil + ring.level = .normal } } - /// Tabular figures with a smaller, quieter unit, so the column reads as numbers. - private static func figure(_ value: String, unit: String, colour: NSColor) -> NSAttributedString { - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = .right + /// Tabular figures with a smaller unit, so the column reads as numbers. + private static func figure(_ value: String, unit: String, level: QuotaLevel?) -> NSAttributedString { + let colour = level.map { QuotaPalette.text(for: $0) } ?? .secondaryLabelColor let text = NSMutableAttributedString(string: value, attributes: [ - .font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium), .foregroundColor: colour, .paragraphStyle: paragraph, + .font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold), .foregroundColor: colour, ]) text.append(NSAttributedString(string: unit, attributes: [ - .font: NSFont.systemFont(ofSize: 9, weight: .medium), .paragraphStyle: paragraph, - .foregroundColor: colour == .labelColor ? NSColor.secondaryLabelColor : colour, + .font: NSFont.systemFont(ofSize: 10, weight: .medium), + .foregroundColor: level == .normal ? NSColor.secondaryLabelColor : colour, ])) return text } - func redrawMeters() { - for line in arrangedSubviews { for case let meter as QuotaMeter in line.subviews { meter.needsDisplay = true } } + func redrawMeters() { ring.needsDisplay = true } +} + +/// Low and critical emphasis for quota figures and rings. In light mode these +/// are darker than systemOrange and systemRed, so a coloured figure keeps +/// 4.5:1 contrast on the popover and on a hovered row. +enum QuotaPalette { + static let low = dynamic(light: (0.627, 0.306, 0), dark: (1, 0.702, 0.251)) + static let critical = dynamic(light: (0.784, 0, 0.102), dark: (1, 0.412, 0.380)) + + static func text(for level: QuotaLevel) -> NSColor { + switch level { + case .critical: critical + case .low: low + case .normal: .labelColor + } + } + + static func ring(for level: QuotaLevel, increaseContrast: Bool) -> NSColor { + level == .normal ? (increaseContrast ? .labelColor : .secondaryLabelColor) : text(for: level) + } + + private static func dynamic(light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat)) -> NSColor { + NSColor(name: nil) { appearance in + let value = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + return NSColor(srgbRed: value.0, green: value.1, blue: value.2, alpha: 1) + } } } -/// A thin capacity bar beside each quota percentage. The number carries the -/// value; low and critical colours only add emphasis. -final class QuotaMeter: NSView { - var fraction: CGFloat = 0 { didSet { needsDisplay = true } } +/// A 20-point ring filled clockwise from the top with a window's remaining +/// share. Any remaining quota shows at least a sliver; no reading shows the +/// track alone. +final class QuotaRing: NSView { + static let size: CGFloat = 20 + private static let lineWidth: CGFloat = 2.5 + var fraction: CGFloat? { didSet { needsDisplay = true } } var level: QuotaLevel = .normal { didSet { needsDisplay = true } } - private let fill = CALayer() + private let track = CAShapeLayer() + private let arc = CAShapeLayer() init() { super.init(frame: .zero) wantsLayer = true - fill.actions = ["bounds": NSNull(), "position": NSNull(), "backgroundColor": NSNull(), "cornerRadius": NSNull()] - layer?.addSublayer(fill) + for shape in [track, arc] { + shape.fillColor = nil + shape.lineWidth = Self.lineWidth + shape.actions = ["path": NSNull(), "strokeColor": NSNull(), "strokeEnd": NSNull(), "hidden": NSNull()] + layer?.addSublayer(shape) + } + arc.lineCap = .round setAccessibilityElement(false) translatesAutoresizingMaskIntoConstraints = false - widthAnchor.constraint(equalToConstant: 32).isActive = true - heightAnchor.constraint(equalToConstant: 4).isActive = true + widthAnchor.constraint(equalToConstant: Self.size).isActive = true + heightAnchor.constraint(equalToConstant: Self.size).isActive = true } @available(*, unavailable) @@ -1024,23 +1100,21 @@ final class QuotaMeter: NSView { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } override func updateLayer() { - guard let layer else { return } let contrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast + let radius = (min(bounds.width, bounds.height) - Self.lineWidth) / 2 + let centre = CGPoint(x: bounds.midX, y: bounds.midY) + let path = CGMutablePath() + // Layer coordinates run upwards, so decreasing angles run clockwise. + path.addArc(center: centre, radius: radius, startAngle: .pi / 2, endAngle: .pi / 2 - 2 * .pi, clockwise: true) + track.path = path + arc.path = path effectiveAppearance.performAsCurrentDrawingAppearance { - layer.backgroundColor = (contrast ? NSColor.tertiaryLabelColor : .quaternaryLabelColor).cgColor - let colour: NSColor = switch level { - case .critical: .systemRed - case .low: .systemOrange - case .normal: contrast ? .labelColor : .secondaryLabelColor - } - fill.backgroundColor = colour.cgColor + track.strokeColor = (contrast ? NSColor.tertiaryLabelColor : .quaternaryLabelColor).cgColor + arc.strokeColor = QuotaPalette.ring(for: level, increaseContrast: contrast).cgColor } - let radius = bounds.height / 2 - layer.cornerRadius = radius - fill.cornerRadius = radius - // Keep any remaining quota visible as at least a dot. - let width = fraction > 0 ? max(bounds.height, bounds.width * min(1, fraction)) : 0 - fill.frame = CGRect(x: 0, y: 0, width: width, height: bounds.height) + let share = min(1, fraction ?? 0) + arc.isHidden = share <= 0 + arc.strokeEnd = max(0.03, share) } } @@ -1063,40 +1137,26 @@ final class RowHighlightView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { nil } } -/// A row's leading mark: a monogram disc for a profile, or a rounded tile -/// holding a symbol for a workspace. Monochrome, like the rest of the menu. +/// A profile's leading mark: a rounded tile holding its monogram. +/// Monochrome, like the rest of the menu. final class RowAvatar: NSView { - private let isDisc: Bool - - convenience init(monogram name: String) { - let letter = NSTextField(labelWithString: name.first.map { String($0).uppercased() } ?? "") - let font = NSFont.systemFont(ofSize: 11, weight: .semibold) - letter.font = font.fontDescriptor.withDesign(.rounded).flatMap { NSFont(descriptor: $0, size: 11) } ?? font - letter.textColor = .secondaryLabelColor - letter.alignment = .center - letter.setAccessibilityElement(false) - self.init(glyph: letter, disc: true) - } - - convenience init(symbol: String) { - let image = NSImageView(image: NSImage(systemSymbolName: symbol, accessibilityDescription: nil) ?? NSImage()) - image.symbolConfiguration = .init(pointSize: 11, weight: .medium) - image.contentTintColor = .secondaryLabelColor - self.init(glyph: image, disc: false) - } - - private init(glyph: NSView, disc: Bool) { - isDisc = disc + init(monogram name: String) { super.init(frame: .zero) wantsLayer = true translatesAutoresizingMaskIntoConstraints = false setAccessibilityElement(false) - glyph.translatesAutoresizingMaskIntoConstraints = false - addSubview(glyph) + 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), - glyph.centerXAnchor.constraint(equalTo: centerXAnchor), glyph.centerYAnchor.constraint(equalTo: centerYAnchor), + letter.centerXAnchor.constraint(equalTo: centerXAnchor), letter.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } @@ -1116,26 +1176,23 @@ final class RowAvatar: NSView { layer.borderColor = NSColor.secondaryLabelColor.cgColor } layer.cornerCurve = .continuous - layer.cornerRadius = isDisc ? LaunchRowButton.avatarSize / 2 : 6 + layer.cornerRadius = 7 } } -/// A hairline between rows of one group. It starts at the title column, as in -/// native inset lists, and hides beside a highlighted row so the plate stands alone. -final class RowRule: NSView { - weak var above: LaunchRowButton? - weak var below: LaunchRowButton? - private let separator = NSBox() - +/// A hairline between profile groups, inset to the content edges. Rows within +/// a group need no rules; the nesting already shows what belongs together. +final class GroupDivider: NSView { init() { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false + let separator = NSBox() separator.boxType = .separator separator.translatesAutoresizingMaskIntoConstraints = false addSubview(separator) NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: 1), - separator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: LaunchRowButton.titleInset), + heightAnchor.constraint(equalToConstant: 9), + separator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: LaunchRowButton.contentInset), separator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -LaunchRowButton.contentInset), separator.centerYAnchor.constraint(equalTo: centerYAnchor), separator.heightAnchor.constraint(equalToConstant: 1), ]) @@ -1143,6 +1200,4 @@ final class RowRule: NSView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func update() { separator.isHidden = above?.showsPlate == true || below?.showsPlate == true } } diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift index 99fe3cf..48b2cb3 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift @@ -17,6 +17,9 @@ enum OpenDestination: String { case .terminal: "terminal" } } + + /// Where Command-Return and Command-click open instead. + var alternate: OpenDestination { self == .chatGPT ? .terminal : .chatGPT } } enum LaunchTarget: Equatable, Identifiable { diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift index f609880..011f42f 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift @@ -18,10 +18,6 @@ final class WorkspaceStore { didSet { notify() } } - var selectedProfile: String? { - didSet { notify() } - } - private(set) var phase: Phase = .idle private(set) var workspaces: [WorkspaceBinding] = [] private(set) var profiles: [String] = [] @@ -36,7 +32,10 @@ final class WorkspaceStore { private(set) var guardMode = "off" private(set) var isRefreshing = false private(set) var launchingID: LaunchTarget.ID? - private(set) var statusMessage = "Choose a profile or workspace" + private(set) var statusMessage = WorkspaceStore.readyMessage + /// Whether the status reports news, such as a launch or a failure, + /// rather than the resting prompt that leaves room for key hints. + var hasStatusNews: Bool { statusMessage != Self.readyMessage && statusMessage != Self.emptyMessage } private let client: CLIClient private let defaults: UserDefaults @@ -60,15 +59,19 @@ final class WorkspaceStore { } var filteredWorkspaces: [WorkspaceBinding] { - sortByRecency(workspaces.filter { - $0.matches(query) && (selectedProfile == nil || $0.profile == selectedProfile) - }) + sortByRecency(workspaces.filter { $0.matches(query) }) } + /// Each profile followed by its own workspaces, pinned first and then most + /// recently opened. Workspaces whose profile is missing come last. A + /// search keeps this order and lists only matches. var filteredTargets: [LaunchTarget] { - profiles.map(LaunchTarget.profile).filter { - $0.matches(query) && (selectedProfile == nil || $0.profile == selectedProfile) - } + filteredWorkspaces.map(LaunchTarget.workspace) + let matching = filteredWorkspaces + let grouped = profiles.flatMap { profile in + [LaunchTarget.profile(profile)].filter { $0.matches(query) } + + matching.filter { $0.profile == profile }.map(LaunchTarget.workspace) + } + return grouped + matching.filter { !profiles.contains($0.profile) }.map(LaunchTarget.workspace) } func isPinned(_ workspace: WorkspaceBinding) -> Bool { @@ -235,14 +238,9 @@ final class WorkspaceStore { profiles = loadedProfiles usage = usage.filter { profiles.contains($0.key) } guardMode = response.guardMode - if let selectedProfile, !profiles.contains(selectedProfile) { - self.selectedProfile = nil - } hasLoaded = true phase = .ready - statusMessage = profiles.isEmpty && workspaces.isEmpty - ? "Create a profile to get started" - : "Choose a profile or workspace" + statusMessage = restingMessage return true } catch { if !hasLoaded { phase = .failed(error.localizedDescription) } @@ -469,11 +467,15 @@ final class WorkspaceStore { guardMode = "warn" hasLoaded = true phase = .ready - statusMessage = profiles.isEmpty && bindings.isEmpty ? "Create a profile to get started" : "Choose a profile or workspace" + statusMessage = restingMessage notify() } #endif + private static let readyMessage = "Choose a profile or workspace" + private static let emptyMessage = "Create a profile to get started" + private var restingMessage: String { profiles.isEmpty && workspaces.isEmpty ? Self.emptyMessage : Self.readyMessage } + private func notify() { onChange?() } diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift index 3d80a62..069296b 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift @@ -32,15 +32,19 @@ struct MenuInteractionTests { firstRunController.onRequestClose = { profileClosed = true } let profileRow = descendants(firstRunController.view).compactMap { $0 as? LaunchRowButton }.first! let usageView = descendants(profileRow).compactMap { $0 as? ProfileUsageView }.first! - expect(descendants(usageView).compactMap { $0 as? NSTextField }.map(\.stringValue) == ["5h", "77%", "7d", "9%"], - "profile rows must show remaining percentages and actual window durations") + let usageText = descendants(usageView).compactMap { $0 as? NSTextField } + expect(usageText.map(\.stringValue) == ["9%", "7d left"], + "profile rows must show the tightest window's remaining percentage and actual duration") + expect(usageText[0].attributedStringValue.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == QuotaPalette.critical, + "a critical window's figure must carry the critical colour") + expect(descendants(usageView).contains { $0 is QuotaRing }, "the figure must sit beside a ring") expect(profileRow.accessibilityLabel()!.contains("77% remaining") && profileRow.accessibilityLabel()!.contains("Resets"), - "quota and reset times must be available to accessibility clients") + "every window's quota and reset time must be available to accessibility clients") expect(usageView.toolTip!.contains("ChatGPT may use a different account") && usageView.toolTip!.contains("Checked"), "quota tooltips must explain sign-in scope and freshness") let profileArguments = try await launchArguments(recordedIn: marker, until: { profileClosed }, - "a profile launch must dismiss the menu and record its arguments") { openButton(in: profileRow).performClick(nil) } - expect(profileArguments == "app\nwork\n", "a profile must open without supplying a workspace path") + "a profile launch must dismiss the menu and record its arguments") { profileRow.performClick(nil) } + expect(profileArguments == "app\nwork\n", "clicking a profile must open it without supplying a workspace path") expect(store.workspaces.isEmpty, "opening a profile must not create a workspace binding") store.loadPreview([], profiles: []) expect(descendants(firstRunController.view).compactMap { $0 as? NSButton }.contains { $0.title == "Create profile…" }, @@ -52,113 +56,118 @@ struct MenuInteractionTests { store.loadPreview([first, missing, last], usage: ["work": quota]) store.togglePin(first) let controller = WorkspaceMenuViewController(store: store, defaults: defaults) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 480), styleMask: .borderless, backing: .buffered, defer: false) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 480), styleMask: .borderless, backing: .buffered, defer: false) window.contentViewController = controller window.setContentSize(controller.preferredContentSize) controller.view.layoutSubtreeIfNeeded() controller.focusSearch() - expect(controller.preferredContentSize.width == 400, "popover must retain compact reference width") + expect(controller.preferredContentSize.width == WorkspaceMenuViewController.width, "popover must retain its compact width") let allViews = descendants(controller.view) expect(!(controller.view is NSVisualEffectView) && !allViews.contains { $0 is NSVisualEffectView }, "NSPopover must own its background material without a duplicate effect layer") - let title = allViews.compactMap { $0 as? NSTextField }.first { $0.stringValue == "Codex Profiles" }! - let options = allViews.compactMap { $0 as? NSButton }.first { $0.toolTip == "Options" }! - let titleCenter = controller.view.convert(NSPoint(x: title.bounds.midX, y: title.bounds.midY), from: title) - let optionsCenter = controller.view.convert(NSPoint(x: options.bounds.midX, y: options.bounds.midY), from: options) - expect(abs(titleCenter.y - optionsCenter.y) < 1, "title and options must share one compact header line") - let headerIcon = title.superview!.subviews.compactMap { $0 as? NSImageView }.first! - expect(isMonochrome(headerIcon.contentTintColor), "header symbol must use native monochrome tint") let searchFields = allViews.compactMap { $0 as? NSSearchField } expect(searchFields.count == 1, "workspace search must use native NSSearchField") let search = searchFields[0] - expect(allViews.contains { $0 is NSPopUpButton }, "profile filter must remain available") - expect(allViews.contains { $0 is NSSegmentedControl }, "launch destination must remain available") + let options = allViews.compactMap { $0 as? NSButton }.first { $0.toolTip == "Options" }! + let searchCenter = controller.view.convert(NSPoint(x: search.bounds.midX, y: search.bounds.midY), from: search) + let optionsCenter = controller.view.convert(NSPoint(x: options.bounds.midX, y: options.bounds.midY), from: options) + expect(abs(searchCenter.y - optionsCenter.y) < 1, "search and options must share one compact header line") + expect(allViews.compactMap { $0 as? NSTextField }.first { $0.stringValue == "Codex Profiles" }!.isHidden, + "search must take the title's place once there is something to search") + expect(!allViews.contains { $0 is NSPopUpButton || $0 is NSSegmentedControl }, + "nesting and Command-Return replace the profile filter and destination control") let initialRows = descendants(controller.view).compactMap { $0 as? LaunchRowButton } let list = descendants(controller.view).compactMap { $0 as? NSScrollView }.first! let listWidth = list.contentView.bounds.width - expect(initialRows.count == 4, "initialized profiles and all bindings must be rendered") + expect(initialRows.map(\.item) == [.profile("work"), .workspace(first), .workspace(missing), .workspace(last)], + "workspaces must follow their profile, pinned first") expect(list.documentView!.bounds.height <= list.contentView.bounds.height + 1, "a short list must fit without clipping the last row or needing to scroll") - expect(initialRows.allSatisfy { abs($0.frame.width - listWidth) < 1 }, "workspace rows must fill the list width") - let separators = descendants(list).compactMap { $0 as? NSBox }.filter { $0.boxType == .separator } - // Profiles, pinned and other workspaces: only the two unpinned workspaces share a group. - expect(separators.count == 1, "rows within a group must have native separators; headings separate groups") + expect(initialRows.allSatisfy { abs($0.frame.width - listWidth) < 1 }, "rows must fill the list width") + expect(!descendants(list).contains { $0 is GroupDivider }, "one profile group needs no divider") let titleColumn = descendants(initialRows[0]).compactMap { $0 as? NSTextField }.first { $0.stringValue == "work" }! // Labels pad their frames; the alignment rect is where the text starts. let titleX = list.documentView!.convert(titleColumn.alignmentRect(forFrame: titleColumn.frame), from: titleColumn.superview).minX - expect(separators.allSatisfy { - let frame = list.documentView!.convert($0.bounds, from: $0) - return abs(frame.minX - titleX) < 1 && abs(frame.maxX - (listWidth - LaunchRowButton.contentInset)) < 1 - }, "row separators must start at the title column and keep the trailing inset") let profileUsage = descendants(initialRows[0]).compactMap { $0 as? ProfileUsageView }.first! - let profileName = descendants(initialRows[0]).compactMap { $0 as? NSTextField }.first { $0.stringValue == "work" }! - let profileOpen = openButton(in: initialRows[0]) - let nameFrame = initialRows[0].convert(profileName.bounds, from: profileName) + let profileActions = actionsButton(in: initialRows[0]) + let nameFrame = initialRows[0].convert(titleColumn.bounds, from: titleColumn) let usageFrame = initialRows[0].convert(profileUsage.bounds, from: profileUsage) - let openFrame = initialRows[0].convert(profileOpen.bounds, from: profileOpen) - expect(usageFrame.width == ProfileUsageView.columnWidth && nameFrame.maxX <= usageFrame.minX && usageFrame.maxX < openFrame.minX, - "quota must fit beside the profile without obscuring its name or Open button") + let actionsFrame = initialRows[0].convert(profileActions.bounds, from: profileActions) + expect(nameFrame.maxX <= usageFrame.minX && usageFrame.maxX <= actionsFrame.minX && actionsFrame.maxX <= initialRows[0].bounds.maxX, + "quota must fit beside the profile without obscuring its name or actions") + expect(abs(initialRows[0].frame.height - 46) < 1, "profile rows must stay compact") for row in initialRows where row.item.workspace != nil { expect(!descendants(row).contains { $0 is ProfileUsageView }, "workspace rows must not duplicate account quota") let workspace = row.item.workspace! let labels = descendants(row).compactMap { $0 as? NSTextField } let name = labels.first { $0.stringValue == workspace.name }! - let path = labels.first { $0.stringValue == "\(workspace.profile) · \(workspace.displayPath())" }! - expect(abs(row.convert(name.bounds.origin, from: name).x - row.convert(path.bounds.origin, from: path).x) < 1, - "workspace profile and path must align with project title") + let detail = labels.first { $0.stringValue == (workspace.availabilityReason.map { "\($0) · " } ?? "") + workspace.displayPath() }! + expect(!labels.contains { $0.stringValue == workspace.profile }, "a nested workspace must not repeat its profile") let icon = descendants(row).compactMap { $0 as? NSImageView }.first! + expect(abs(list.documentView!.convert(icon.frame, from: icon.superview).minX - titleX) < 1, + "a nested workspace must start at its profile's title column") expect(isMonochrome(icon.contentTintColor), "folder and pin symbols must use native monochrome tint") - let open = openButton(in: row) - expect(open.controlSize == .small, "explicit Open action must use a compact native button") - expect(open.isEnabled == workspace.isAvailable, "Open availability must match workspace availability") - expect(abs(row.frame.height - (workspace.isAvailable ? 48 : 64)) < 1, - "rows must remain compact while leaving space for missing-folder guidance") + if workspace.isAvailable { + expect(abs(baseline(of: name) - baseline(of: detail)) < 1, "a workspace's name and path must share one line") + expect(actionsButton(in: row).alphaValue == 0, "resting rows must keep their actions out of sight") + } else { + expect(baseline(of: name) > baseline(of: detail) + 8, "a missing folder must explain itself on a second line") + expect(actionsButton(in: row).alphaValue == 1, "rows that need repair must always show their actions") + expect(descendants(row).compactMap { $0 as? NSButton }.contains { $0.title == "Locate…" }, "a missing folder must offer to locate it") + } + expect(row.isEnabled == workspace.isAvailable, "launching must match workspace availability") + expect(abs(row.frame.height - (workspace.isAvailable ? 30 : 44)) < 1, + "workspace rows must stay to one line unless they explain a missing folder") } - expect(initialRows.allSatisfy { !$0.isSelectedRow }, "opening must not paint an unsolicited grey selection") - expect(initialRows.first(where: { $0.item.workspace == missing })?.isEnabled == false, "missing workspace must not launch") - expect(descendants(initialRows.first(where: { $0.item.workspace == missing })!).contains { ($0 as? NSButton)?.toolTip?.contains("remove binding") == true }, "missing rows must retain an actions button") + expect(initialRows.allSatisfy { !$0.isSelectedRow }, "opening must not paint an unsolicited selection") expect(controller.handleShortcut(key(125)), "down arrow must be handled") expect(initialRows.first?.item == .profile("work") && initialRows.first?.isSelectedRow == true, - "first down arrow must select the profile before optional workspaces") + "first down arrow must select the profile before its workspaces") expect(controller.handleShortcut(key(125)), "next down arrow must be handled") - expect(initialRows.first(where: { $0.item.workspace == first })?.isSelectedRow == true, - "next down arrow must select the first available workspace") + let firstRow = initialRows.first { $0.item.workspace == first }! + expect(firstRow.isSelectedRow && actionsButton(in: firstRow).alphaValue == 1, + "next down arrow must select the first available workspace and reveal its actions") expect(controller.handleShortcut(key(125)), "second down arrow must be handled") expect(initialRows.first(where: { $0.item.workspace == last })?.isSelectedRow == true, "arrow must skip unavailable workspace") - let ruleBesideSelection = descendants(initialRows.first { $0.item.workspace == last }!.ruleAbove!).compactMap { $0 as? NSBox }.first! - expect(ruleBesideSelection.isHidden, "a rule beside the selected row must step aside for its plate") + expect(actionsButton(in: firstRow).alphaValue == 0, "actions must hide again once the selection moves on") + let footer = allViews.compactMap { $0 as? NSTextField }.first { $0.stringValue.hasPrefix("↩ ") }! + expect(footer.stringValue == "↩ Open last in ChatGPT · ⌘↩ Terminal", "the footer must name what Return and Command-Return open") expect(controller.handleShortcut(key(126)), "up arrow must be handled") - expect(initialRows.first(where: { $0.item.workspace == first })?.isSelectedRow == true, "up arrow must restore prior selection") - expect(!ruleBesideSelection.isHidden, "a rule must return once neither neighbour is highlighted") - let actions = descendants(initialRows.first { $0.item.workspace == first }!).compactMap { $0 as? NSButton }.first { $0.toolTip?.contains("remove binding") == true }! - window.makeFirstResponder(actions) + expect(firstRow.isSelectedRow, "up arrow must restore prior selection") + window.makeFirstResponder(actionsButton(in: firstRow)) expect(!controller.handleShortcut(key(36)), "Return on an actions button must retain its native action") + expect(!controller.handleShortcut(key(36, modifiers: .command)), "Command-Return on a native control must retain native behaviour") expect(!controller.handleShortcut(key(125)), "arrows on a native control must retain native behaviour") controller.focusSearch() expect(initialRows.allSatisfy { !$0.isSelectedRow }, "focusing search on reopening must clear old selection") + expect(footer.stringValue == "↩ Open in ChatGPT · ⌘↩ Terminal", "without a selection the footer must describe both keys") expect(controller.handleShortcut(key(126)), "up arrow must be handled from an unselected list") expect(initialRows.first(where: { $0.item.workspace == last })?.isSelectedRow == true, "first up arrow must select the last available workspace") controller.focusSearch() search.stringValue = "last" expect(search.sendAction(search.action, to: search.target), "native search action must have a receiver") - expect(descendants(controller.view).compactMap { $0 as? LaunchRowButton }.count == 1, "search must replace list with matching workspace") - expect(descendants(controller.view).compactMap { $0 as? LaunchRowButton }.allSatisfy { !$0.isSelectedRow }, - "typing a new search must clear prior keyboard selection") + let searchRows = descendants(controller.view).compactMap { $0 as? LaunchRowButton } + expect(searchRows.count == 1, "search must replace list with matching workspace") + expect(searchRows[0].isSelectedRow, "typing a search must select its first match so Return's target is visible") + expect(descendants(searchRows[0]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "work" }, + "a match listed without its profile must name the profile") search.stringValue = "" _ = search.sendAction(search.action, to: search.target) expect(store.query.isEmpty && descendants(controller.view).compactMap { $0 as? LaunchRowButton }.count == 4, "native search cancel action must restore all workspaces") + expect(descendants(controller.view).compactMap { $0 as? LaunchRowButton }.allSatisfy { !$0.isSelectedRow }, + "clearing the search must leave the list unselected") search.stringValue = "does-not-exist" _ = search.sendAction(search.action, to: search.target) window.setContentSize(controller.preferredContentSize) controller.view.layoutSubtreeIfNeeded() - let reset = descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Reset filters" }! - expect(controller.view.convert(reset.bounds, from: reset).minY >= 50, - "the no-match reset button must fit above the footer with padding") - reset.performClick(nil) + let clear = descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Clear search" }! + expect(controller.view.convert(clear.bounds, from: clear).minY >= 50, + "the no-match button must fit above the footer with padding") + clear.performClick(nil) expect(store.query.isEmpty && descendants(controller.view).compactMap { $0 as? LaunchRowButton }.count == 4, - "reset filters must restore the profile and workspace rows") + "clearing the search must restore the profile and workspace rows") search.stringValue = "last" _ = search.sendAction(search.action, to: search.target) var closed = false @@ -178,11 +187,11 @@ struct MenuInteractionTests { closed = false search.stringValue = "first" _ = search.sendAction(search.action, to: search.target) - let firstRow = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first! + let firstMatch = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first! let clickArgs = try await launchArguments(recordedIn: marker, until: { closed }, - "successful explicit Open action must dismiss popover and record its arguments") { openButton(in: firstRow).performClick(nil) } - expect(clickArgs.contains(first.path), "Open must launch its own workspace") - expect(!clickArgs.contains(last.path), "Open must replace the prior launch arguments") + "a successful click must dismiss popover and record its arguments") { firstMatch.performClick(nil) } + expect(clickArgs.contains(first.path), "clicking must launch its own workspace") + expect(!clickArgs.contains(last.path), "clicking must replace the prior launch arguments") closed = false search.stringValue = "last" _ = search.sendAction(search.action, to: search.target) @@ -230,6 +239,14 @@ struct MenuInteractionTests { } expect(borders[0] < 0.5 && borders[1] > 0.5 && borders[2] < 0.5, "custom row decoration must resolve semantic colours again when appearance changes") + // Emphasis colours resolve per appearance: darker in light mode for contrast, lighter in dark mode. + var criticalRed: [CGFloat] = [] + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + NSAppearance(named: appearance)!.performAsCurrentDrawingAppearance { + criticalRed.append(QuotaPalette.critical.usingColorSpace(.sRGB)!.greenComponent) + } + } + expect(criticalRed[0] < 0.1 && criticalRed[1] > 0.3, "quota emphasis must adapt to light and dark appearance") let expired = ProfileUsage.available(CodexRateLimits(windows: [RateLimitWindow(usedPercent: 80, windowDurationMins: nil, resetsAt: 1)]), checkedAt: checkedAt) store.loadPreview([], profiles: ["very-long-profile-name-for-a-client", "work"], usage: [ "work": expired, "very-long-profile-name-for-a-client": .unavailable(checkedAt: checkedAt), @@ -239,13 +256,14 @@ struct MenuInteractionTests { let quotaRows = descendants(controller.view).compactMap { $0 as? LaunchRowButton } expect(descendants(quotaRows[0]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "Unavailable" }, "missing quota must say Unavailable rather than zero") - expect(openButton(in: quotaRows[0]).isEnabled, "unavailable quota must not disable Open") - expect(descendants(quotaRows[1]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "—" }, + expect(quotaRows[0].isEnabled, "unavailable quota must not prevent launching") + expect(descendants(quotaRows[1]).compactMap { $0 as? NSTextField }.map(\.stringValue).contains("—"), "a passed reset must not show stale percentages") + expect(descendants(controller.view).filter { $0 is GroupDivider }.count == 1, "separate profiles must be divided into groups") let longName = descendants(quotaRows[0]).compactMap { $0 as? NSTextField }.first { $0.stringValue == "very-long-profile-name-for-a-client" }! expect(longName.lineBreakMode == .byTruncatingTail && longName.toolTip == longName.stringValue, "long profile names must truncate while retaining the full name on hover") - store.loadPreview((0..<12).map { workspace("/projects/project-\($0)") }) + store.loadPreview((0..<16).map { workspace("/projects/project-\($0)") }) window.setContentSize(controller.preferredContentSize) controller.view.layoutSubtreeIfNeeded() let longList = descendants(controller.view).compactMap { $0 as? NSScrollView }.first! @@ -253,39 +271,58 @@ struct MenuInteractionTests { expect(longList.documentView!.bounds.height > longList.contentView.bounds.height, "projects beyond the height limit must remain in the scrollable document") + // A workspace whose profile is missing follows the groups and names its profile. + let orphan = WorkspaceBinding(path: "/projects/orphan", profile: "gone", pathExists: true, profileExists: false) + store.loadPreview([first, orphan], profiles: ["work"]) + let orphanRows = descendants(controller.view).compactMap { $0 as? LaunchRowButton } + expect(orphanRows.map(\.item) == [.profile("work"), .workspace(first), .workspace(orphan)] + && descendants(controller.view).filter { $0 is GroupDivider }.count == 1, + "a workspace without a listed profile must follow the profile groups after a divider") + expect(descendants(orphanRows[2]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "gone" } && !orphanRows[2].isEnabled, + "a workspace without its profile must name it and refuse to launch") + // Usage readings stream in after opening; they must not rebuild the list. store.loadPreview([first], profiles: ["work", "personal"], usage: ["work": quota]) - let picker = descendants(controller.view).compactMap { $0 as? NSPopUpButton }.first! - let pickerItem = picker.item(at: 1) let stableRow = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first { $0.item == .profile("work") }! let lowQuota = ProfileUsage.available(CodexRateLimits(windows: [ RateLimitWindow(usedPercent: 95, windowDurationMins: 300, resetsAt: checkedAt.addingTimeInterval(4320).timeIntervalSince1970), ]), checkedAt: checkedAt) store.loadPreview([first], profiles: ["work", "personal"], usage: ["work": lowQuota], readingUsage: ["work"]) let updatedRow = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first { $0.item == .profile("work") }! - expect(updatedRow === stableRow && picker.item(at: 1) === pickerItem, - "a usage reading must update rows in place without rebuilding the list or the profile filter") + expect(updatedRow === stableRow, "a usage reading must update rows in place without rebuilding the list") let updatedText = descendants(updatedRow).compactMap { $0 as? NSTextField }.map(\.stringValue) - expect(updatedText.contains("5%") && updatedText.contains { $0.hasPrefix("5h limit low · resets in ") }, + expect(updatedText.contains("5%") && updatedText.contains("5h left") && updatedText.contains { $0.hasPrefix("5h limit low · resets in ") }, "the row must show the newer reading and when the low window resets") let refreshingUsage = descendants(updatedRow).compactMap { $0 as? ProfileUsageView }.first! expect(refreshingUsage.arrangedSubviews.allSatisfy { $0.alphaValue < 1 } && refreshingUsage.toolTip!.contains("Refreshing"), "a refresh must dim the previous reading rather than hide it") - expect(descendants(refreshingUsage).contains { $0 is QuotaMeter }, "each quota window must have a meter beside its percentage") // A row whose binding is changing shows progress and refuses launches. store.loadPreview([first, last], profiles: ["work"], usage: ["work": quota], changingPaths: [last.path]) let changingRow = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first { $0.item.workspace == last }! - expect(!changingRow.isEnabled && openButton(in: changingRow).title == "Updating…" && !openButton(in: changingRow).isEnabled, + expect(!changingRow.isEnabled && descendants(changingRow).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "Updating…" }, "a row being changed must show progress and refuse launches") - expect(descendants(changingRow).compactMap { $0 as? NSButton }.first { $0.toolTip?.contains("remove binding") == true }?.isEnabled == false, - "a row being changed must not accept another change") + expect(!actionsButton(in: changingRow).isEnabled, "a row being changed must not accept another change") controller.focusSearch() _ = controller.handleShortcut(key(125)) _ = controller.handleShortcut(key(125)) _ = controller.handleShortcut(key(125)) expect(changingRow.isSelectedRow == false, "keyboard selection must skip a row being changed") + // The profile's actions list every window, which the row's single figure leaves out. + let usageMenu = controller.actionsMenu(for: .profile("work")) + let usageLines = usageMenu.items.prefix { !$0.isSeparatorItem } + expect(usageLines.map(\.title).first == "Codex CLI usage" && usageLines.allSatisfy { !$0.isEnabled } + && usageLines.contains { $0.title.hasPrefix("5h: 77% left · resets ") } && usageLines.contains { $0.title.hasPrefix("7d: 9% left · resets ") } + && usageLines.contains { $0.title.hasPrefix("Checked ") }, + "profile actions must list each window's remaining quota, reset time and freshness as information") + let passed = ProfileUsage.available(CodexRateLimits(windows: [ + RateLimitWindow(usedPercent: 60, windowDurationMins: 300, resetsAt: checkedAt.addingTimeInterval(-60).timeIntervalSince1970), + ]), checkedAt: checkedAt) + store.loadPreview([first, last], profiles: ["work"], usage: ["work": passed]) + expect(controller.actionsMenu(for: .profile("work")).items.contains { $0.title.hasPrefix("5h: reset at ") && $0.title.hasSuffix(" · awaiting a fresh reading") }, + "a window whose reset has passed must keep its reset time and say it awaits a fresh reading") + // Disabled actions stay disabled through AppKit's menu validation. store.loadPreview([first, missing], profiles: ["work", "personal"]) let missingMenu = controller.actionsMenu(for: .workspace(missing)) @@ -303,12 +340,17 @@ struct MenuInteractionTests { let profileMenu = controller.actionsMenu(for: .profile("work")) expect(["Open in ChatGPT", "Open in Terminal", "Add workspace…", "Sign in to Codex CLI…"].allSatisfy { item($0, in: profileMenu).isEnabled }, "profile actions must offer both destinations, a workspace shortcut and CLI sign-in") + let rowWithMenu = descendants(controller.view).compactMap { $0 as? LaunchRowButton }.first { $0.item.workspace == first }! + expect(rowWithMenu.menu(for: NSEvent.mouseEvent(with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, + 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") // A row action opens the other destination without changing the default. defaults.set(OpenDestination.terminal.rawValue, forKey: "openDestination") let login = FakeLoginItem() let terminalController = WorkspaceMenuViewController(store: store, defaults: defaults, loginItem: login) - _ = terminalController.view + let terminalWindow = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 480), styleMask: .borderless, backing: .buffered, defer: false) + terminalWindow.contentViewController = terminalController var terminalClosed = false terminalController.onRequestClose = { terminalClosed = true } let alternate = terminalController.actionsMenu(for: .profile("work")) @@ -318,6 +360,24 @@ struct MenuInteractionTests { } expect(alternateArguments == "app\nwork\n", "Open in ChatGPT must launch ChatGPT while Terminal stays the default") expect(defaults.string(forKey: "openDestination") == OpenDestination.terminal.rawValue, "a row action must not change the default destination") + terminalController.focusSearch() + terminalClosed = false + let commandReturnArguments = try await launchArguments(recordedIn: marker, until: { terminalClosed }, + "Command-Return must dismiss the menu and record its arguments") { + expect(terminalController.handleShortcut(key(36, modifiers: .command)), "Command-Return must be handled from search") + } + expect(commandReturnArguments == "app\npersonal\n", "Command-Return must open the first row in ChatGPT while Terminal is the default") + var returnOpens = item("Return opens in", in: terminalController.settingsMenu()).submenu! + expect(item("Terminal", in: returnOpens).state == .on && item("ChatGPT", in: returnOpens).state == .off, + "the options menu must show which destination Return opens") + returnOpens.performActionForItem(at: returnOpens.indexOfItem(withTitle: "ChatGPT")) + returnOpens = item("Return opens in", in: terminalController.settingsMenu()).submenu! + expect(defaults.string(forKey: "openDestination") == OpenDestination.chatGPT.rawValue && item("ChatGPT", in: returnOpens).state == .on, + "choosing a destination must persist it") + // A launch reports its outcome in the footer until the next reload. + store.loadPreview([first, missing], profiles: ["work", "personal"]) + expect(descendants(terminalController.view).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "↩ Open in ChatGPT · ⌘↩ Terminal" }, + "the footer must follow the chosen destination") // Open at Login reflects and changes the login item. var settings = terminalController.settingsMenu() @@ -442,8 +502,12 @@ struct MenuInteractionTests { private static func descendants(_ view: NSView) -> [NSView] { view.subviews.flatMap { [$0] + descendants($0) } } - private static func openButton(in row: LaunchRowButton) -> NSButton { - descendants(row).compactMap { $0 as? NSButton }.first { ["Open", "Opening…", "Updating…"].contains($0.title) }! + private static func actionsButton(in row: LaunchRowButton) -> NSButton { + descendants(row).compactMap { $0 as? NSButton }.first { $0.toolTip?.hasPrefix("Open elsewhere") == true }! + } + /// A label's first baseline in window coordinates, which run upwards. + private static func baseline(of label: NSTextField) -> CGFloat { + label.convert(label.bounds, to: nil).maxY - label.firstBaselineOffsetFromTop } /// Polls until a condition holds or 10 seconds pass. Work usually settles /// within milliseconds; the bound only absorbs a loaded machine, and the diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift index 385de4b..e8e7b22 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift @@ -306,6 +306,9 @@ struct CodexProfilesMenuTests { let store = fixture.store() expect(await store.refresh(), "store did not refresh") expect(store.guardMode == "warn", "workspace guard mode was not retained") + expect(!store.hasStatusNews, "a loaded store must rest on its prompt so the footer can show key hints") + store.showMessage("Copied workspace path") + expect(store.hasStatusNews, "a reported outcome must count as news") expect(store.filteredWorkspaces == [gamma, beta, alpha], "recent workspaces were not ordered") store.togglePin(alpha) expect(store.filteredWorkspaces == [alpha, gamma, beta], "pinned workspace did not precede recents") @@ -313,13 +316,13 @@ struct CodexProfilesMenuTests { await restored.refresh() expect(restored.isPinned(alpha), "pin was not persisted") expect(restored.filteredWorkspaces == [alpha, gamma, beta], "restored pin ordering differs") - restored.selectedProfile = "work" + expect(restored.filteredTargets == [.profile("default"), .profile("personal"), .workspace(beta), .profile("work"), .workspace(alpha), .workspace(gamma)], + "each profile must lead its own workspaces, pinned first and then most recent") restored.query = "ALPHA" - expect(restored.filteredWorkspaces == [alpha], "profile and search filters were not combined") - restored.query = "beta" - expect(restored.filteredWorkspaces.isEmpty, "search bypassed the selected profile") - restored.selectedProfile = nil - expect(restored.filteredWorkspaces == [beta], "clearing profile filter did not restore search result") + expect(restored.filteredWorkspaces == [alpha] && restored.filteredTargets == [.workspace(alpha)], + "a search must ignore case and list a match without its unmatched profile") + restored.query = "personal" + expect(restored.filteredTargets == [.profile("personal"), .workspace(beta)], "a profile search must keep that profile's workspaces") restored.query = "" restored.togglePin(alpha) expect(restored.filteredWorkspaces == [gamma, beta, alpha], "unpin did not restore recency ordering") @@ -334,7 +337,6 @@ struct CodexProfilesMenuTests { try fixture.writeWorkspaces([alpha]) let store = fixture.store() await store.refresh() - store.selectedProfile = "work" try fixture.write("", to: "hold-refresh") try FileManager.default.removeItem(at: fixture.url("refresh-started")) let first = Task { await store.refresh() } @@ -353,16 +355,14 @@ struct CodexProfilesMenuTests { expect(firstSucceeded && secondSucceeded, "queued refresh did not finish") expect(!FileManager.default.fileExists(atPath: fixture.url("refresh-overlap").path), "workspace loads overlapped") expect(try fixture.read("refresh-calls").split(whereSeparator: \.isNewline).count == 3, "overlapping requests did not produce one serial reload") - expect(store.selectedProfile == "work", "valid profile filter was cleared") try fixture.write("registry unavailable", to: "refresh-error") expect(!(await store.refresh()), "failed refresh reported success") expect(store.phase == .ready && store.workspaces == [alpha], "failed refresh discarded loaded content") expect(!store.isRefreshing && store.statusMessage.contains("registry unavailable"), "refresh error was not visible") try FileManager.default.removeItem(at: fixture.url("refresh-error")) - store.selectedProfile = "personal" try fixture.write("default\nwork\n", to: "profiles") await store.refresh() - expect(store.selectedProfile == nil, "removed profile filter was retained") + expect(store.profiles == ["default", "work"] && !store.filteredTargets.contains(.profile("personal")), "a removed profile was retained") try fixture.write("not json", to: "workspaces.json") expect(!(await store.refresh()), "invalid JSON was accepted") expect(store.workspaces == [alpha] && store.statusMessage.contains("Could not read workspace bindings"), "invalid JSON lost content or useful error") @@ -491,9 +491,8 @@ struct CodexProfilesMenuTests { expect(await store.refresh(), "profiles could not load without workspace bindings") expect(store.filteredTargets == [.profile("default"), .profile("personal"), .profile("work")], "all initialized profiles must be offered before any folders are added") - store.selectedProfile = "work" store.query = "WORK" - expect(store.filteredTargets == [.profile("work")], "profile search and filtering must work without folders") + expect(store.filteredTargets == [.profile("work")], "profile search must work without folders") expect(await store.launch(.profile("work"), in: .chatGPT), "profile-only launch failed") expect(try fixture.read("arguments").hasSuffix("app\u{0}work\u{0}\n"), "profile launch must not fabricate a workspace argument") diff --git a/macos/README.md b/macos/README.md index 97c1d04..8dc2b54 100644 --- a/macos/README.md +++ b/macos/README.md @@ -4,8 +4,8 @@ The native menu-bar companion bundles the existing `codex-profile` engine. It runs on macOS 13 or newer, on Apple silicon and Intel. It requires neither Swift nor a separate `codex-profile` installation on the user's Mac. -Drag **Codex Profiles.app** to **Applications**, open it, and choose -**Open** beside an existing profile. If none exists, choose **Create profile…** +Drag **Codex Profiles.app** to **Applications**, open it, and click an +existing profile. If none exists, choose **Create profile…** and enter a name such as `work` or `personal`. A project folder is not required. The app opens its menu when you launch it. **New profile…** is also in its gear menu. **Add workspace…** optionally binds a project folder to a profile for a @@ -30,25 +30,31 @@ a server-side account boundary. ## Menu and keyboard -The menu uses system search, a profile filter and a ChatGPT/Terminal -destination control. Profiles appear first and can open without a folder. -Pinned projects follow; other projects follow their last successful launch. -Workspace rows show the project name, profile and folder, -with an explicit **Open** button and an actions menu. Missing folders and -profiles show the reason and retain actions for repair or removal. Refresh +The menu is a single search field above a list. Each profile leads its own +group and opens without a folder. Its projects sit beneath it on one line +each, with the folder path: pinned projects first, then the rest by their +last successful launch. Projects whose profile is missing follow the groups. +Click a row to open it; its ⋯ actions button appears on hover or selection, +and right-click shows the same actions. Missing folders and profiles show the +reason, keep their actions visible and offer **Locate…** for a moved folder. Refresh failures preserve loaded content while retaining committed binding changes. While a binding change is in progress, its row shows **Updating…** and cannot open, so it never launches the previous profile. Every row's actions menu offers **Open in ChatGPT** and **Open in Terminal**, -which use that destination once without changing the default. Profile rows -also offer **Add workspace…** for that profile and **Sign in to Codex CLI…**. +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…**. Actions that cannot apply, such as revealing a missing folder, are disabled. -Search receives focus when the menu opens. ↑/↓ selects available profiles or -projects; Return opens the selection, or the first available search result. -⌘1–9 opens the corresponding visible row, ⌘K focuses search, and ⌘R refreshes. -Escape clears search before closing. Successful launches close the popover. +Search receives focus when the menu opens. Typing selects the first +available match. ↑/↓ selects available profiles or projects; Return opens the +selection, or the first available row, and ⌘Return opens it in the other +destination, as ⌘-click does. The footer says which destination each key +uses. ⌘1–9 opens the corresponding visible row, ⌘K focuses search, and ⌘R +refreshes. Escape clears search before closing. Successful launches close the +popover. Profile-only launches start in the home directory. Adding a project shortcut is a separate action; creating a profile never opens a folder picker unless @@ -56,15 +62,17 @@ you explicitly started **Add workspace…**. ## Codex usage beside each profile -The **Codex left** column shows the percentage remaining in each reported -quota window, with a small meter beside it. Window labels come from the -response: for example, `5h` and `7d`, or `15m` when that is the actual -duration. At 25% or less a meter turns orange, and at 10% or less it and its -number turn red; the number always carries the value. A low window also -replaces the row's description with its reset time, such as -`5h limit low · resets in 1h 12m`. Hover a reading for each reset time and -when it was checked. Missing limits show **Unavailable**; expired readings -show a dash until refreshed. Neither state disables **Open**. +Each profile row shows its tightest reported quota window: the percentage +remaining, the window's duration, such as `5h left`, and a ring filled with +the remaining share. Window labels come from the response: for example, `5h` +and `7d`, or `15m` when that is the actual duration. At 25% or less the ring +and number turn orange, and at 10% or less they turn red; the number always +carries the value. A low window also replaces the row's description with its +reset time, such as `5h limit low · resets in 1h 12m`. The profile's ⋯ menu +lists every window with its remaining share and reset time, and when it was +checked; hover a reading for the same detail. Missing limits show +**Unavailable**; expired readings show a dash until refreshed. Neither state +prevents opening the profile. Usage belongs to each profile's **Codex CLI** sign-in. Desktop sign-in can use a different account; the companion does not compare accounts. Sign in @@ -162,8 +170,8 @@ with sample quota values for layout review. They use an opaque system backdrop for layout review; live translucency is 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 +Native profile and workspace menu in light appearance +Native profile and workspace menu in dark appearance See the [design notes](CodexProfilesMenu/DESIGN.md) for Apple guidance and interaction behaviour. Re-render the previews after changing the view: