Skip to content

Add configurable menu bar from cmux.json - #4052

Closed
lawrencecchen wants to merge 19 commits into
mainfrom
feat-configurable-menubar
Closed

lawrencecchen wants to merge 19 commits into
mainfrom
feat-configurable-menubar

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds ui.menuBar support for custom top-level menus, explicit extends, duplicate-title preservation, action refs, inline commands, separators, nested submenus, and dynamic bash-backed menu sources.
  • Adds dynamic source refresh modes (onOpen, manual, onConfigReload, interval) with trust gating, timeout/output limits, last-good cached items, failure UI, notifications, and Task Manager visibility.
  • Updates schema, pseudo docs, localization, and config resolver tests.

Testing

  • ./scripts/reload.sh --tag menubar
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-menubar-tests -only-testing:cmuxTests/CmuxConfigContextMenuTests test
  • jq empty web/data/cmux.schema.json && jq empty Resources/Localizable.xcstrings
  • plutil -lint GhosttyTabs.xcodeproj/project.pbxproj
  • git diff --check

Demo Video

  • Video URL or attachment: Not recorded. Tagged local build is available for dogfood verification.

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by CodeRabbit

  • New Features
    • Configurable macOS menu bar: top-level menus, separators, nested submenus, inline actions, dynamic menu sources (manual/interval refresh), action execution, and task-manager exposure.
  • Bug Fixes
    • Better validation and clearer error reporting for invalid menus, duplicate separators, nested dynamic sources, and missing action references.
  • Documentation
    • Updated config examples and JSON schema for ui.menuBar plus docs examples.
  • Tests
    • Added decoding, resolution, dynamic-source, nesting and error-reporting tests.

Review Change Stack


Note

High Risk
Introduces a new menu-bar execution path that can run user-supplied shell commands (dynamic sources) and mutates the macOS main menu at runtime; bugs here could impact security/trust prompts, menu stability, or background process management.

Overview
Adds ui.menuBar support in CmuxConfigStore/CmuxConfigUI to define custom top-level menus and menu extensions, with validated parsing/resolution for action refs, inline actions, separators, nested submenus, placement (before/after), and config issues (.menuBarInvalidMenu).

Introduces ConfiguredMenuBarController to apply these menus to NSApp.mainMenu, execute selected actions via AppDelegate, and implement dynamic menu sources that run Bash, decode JSON menu items, cache last-good output, enforce timeout/output limits, require trust for project-local interval refresh, and emit user notifications on failures.

Extends Task Manager payloads/UI to include dynamic menu sources (state, PIDs, resources) and updates docs/schema/localizations/tests accordingly; CI e2e recording is made more robust by preferring screencapture, adding safer shutdown/transcoding, and falling back to screenshot-based video generation.

Reviewed by Cursor Bugbot for commit 9ace32b. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented May 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 14, 2026 1:55pm
cmux-staging Building Building Preview, Comment May 14, 2026 1:55pm

@coderabbitai

coderabbitai Bot commented May 13, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a configurable macOS menu-bar: new UI config and resolved models for ui.menuBar, CmuxConfigStore loading/resolution and published resolved menus, dynamic-source authorization helpers, AppDelegate rendering with observers and debounced refresh, dynamic-source execution/refresh and JSON decoding, task-manager/terminal payload integration, tests, docs/schema, and localization.

Changes

Menu-bar configuration and dispatch

Layer / File(s) Summary
Menu bar configuration and resolved data models
Sources/CmuxConfigUI.swift
Defines CmuxMenuBarUIDefinition, CmuxConfigMenuDefinition, CmuxConfigMenuBarActionItem, CmuxConfigMenuBarDynamicItem, CmuxConfigMenuBarItem with custom Codable handling, validation, and resolved counterparts CmuxResolvedMenuBarMenu, CmuxResolvedMenuBarItem, and CmuxResolvedMenuBarDynamicSource for runtime use.
Config store loading and resolution
Sources/CmuxConfig.swift
Extends CmuxConfigStore to load ui.menuBar.menus (local-first, global-fallback), resolve menus/items/actions into CmuxResolved* models, validate constructs (separators, nested submenus, dynamic source rules), aggregate CmuxConfigIssue entries, publish menuBarMenus/menuBarExtensions, and post .cmuxConfigStoreDidChange.
Dynamic-source authorization helpers
Sources/CmuxConfigExecutor.swift
Adds dynamicMenuSourceTrustDescriptor, authorizeDynamicMenuSourceIfNeeded, and isTrustedDynamicMenuSource to sanitize and authorize dynamic-source commands before execution.
ConfiguredMenuBarController runtime and command execution
Sources/ConfiguredMenuBarController.swift
New @MainActor controller that constructs NSMenu/NSMenuItem trees from resolved config, schedules and runs dynamic-source commands (/bin/bash -lc <command>) with timeout/output limits and bounded capture, decodes JSON-generated menu items, updates cached UI state, manages timers/tasks, and exposes payloads and handlers for AppDelegate.
AppDelegate menu rendering and execution
Sources/AppDelegate.swift
Installs controller observers with debounced refresh, lazily caches the controller, builds a runtime context for menu actions, exposes performConfiguredMenuBarAction → executeConfiguredCmuxAction entry point, and surfaces dynamic-menu failures as notifications.
Task manager and Terminal integration
Sources/TaskManagerSnapshot.swift, Sources/TaskManagerTypes.swift, Sources/TerminalController*.swift
Adds dynamic_menus to top payload generation and snapshots, new task-manager row kinds and resource defaults, annotates PIDs from dynamic menus via v2AnnotateTopDynamicMenus, and includes dynamic_menus in v2 system.top roundtrips.
Tests, docs, schema, project, and localization
cmuxTests/CmuxConfigContextMenuTests.swift, web/app/[locale]/docs/configuration/page.tsx, web/data/cmux.schema.json, Resources/Localizable.xcstrings, GhosttyTabs.xcodeproj
Adds decoding/resolution tests for ui.menuBar (including global/local precedence and generated-source rejection), updates documentation examples and JSON schema with menuBar* defs and examples, adds localized strings for dynamic-menu UI/dialogs, and registers the new controller in the Xcode project. Includes a small ContentView issue-format branch.

Sequence Diagram(s)

sequenceDiagram
  participant AppDelegate
  participant ConfigStore as CmuxConfigStore
  participant NSAppMainMenu as NSApp.mainMenu
  participant Executor as CmuxConfigExecutor
  participant ConfigController as ConfiguredMenuBarController
  participant ActionExecutor as executeConfiguredCmuxAction
  participant User

  AppDelegate->>ConfigStore: installConfiguredMenuBarObserversIfNeeded()
  ConfigStore-->>AppDelegate: publish menuBarMenus / menuBarExtensions
  AppDelegate->>ConfigController: refresh / build NSMenu trees
  ConfigController->>NSAppMainMenu: insert/update NSMenu items (actions, dynamic sources)
  User->>NSAppMainMenu: open dynamic submenu / click menu item
  NSAppMainMenu->>ConfigController: request dynamic content or call action handler
  ConfigController->>Executor: authorizeDynamicMenuSourceIfNeeded (if dynamic)
  Executor-->>ConfigController: sanitized command / trust result
  ConfigController->>ConfigController: run command (bash), decode JSON, update cache
  ConfigController->>AppDelegate: notify dynamic failure (if any)
  AppDelegate->>ActionExecutor: executeConfiguredCmuxAction(action, MainWindowContext)
  ActionExecutor-->>AppDelegate: action result (stdout/stderr/exit/duration)
  AppDelegate->>NSAppMainMenu: update dynamic submenu contents or error state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#3290: Related task-manager/top infrastructure changes that this PR extends by adding dynamic_menus integration.
  • manaflow-ai/cmux#3084: Related ui/action registry and configured-action plumbing that this change builds upon.

Poem

🐰 I nibble JSON crumbs and hop through the gui,

Menus sprout like carrots with dynamic supply,
Commands run, stdout hums, errors copied with cheer,
Submenus refresh, the status appears,
Hooray — the menu bar sings, and the rabbit grins wide!


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (5 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error ConfiguredMenuBarRuntimeContext struct lacks explicit @MainActor isolation despite containing a @MainActor reference (CmuxConfigStore?). Per rules, public value models must be explicitly marked. Add @MainActor marker to ConfiguredMenuBarRuntimeContext struct definition
Cmux Swift Blocking Runtime ❌ Error Unfixed timer cancellation bug: DispatchSourceTimer.cancel() called on suspended timer if process.run() throws. Manual NSLock usage for synchronization. Interval-based DispatchSourceTimers introduced. Arm timer before process.run(): move schedule/setEventHandler/resume before try-catch. Replace NSLock with actor coordination or explicit signals.
Cmux Swift @Concurrent ❌ Error ConfiguredMenuBarDynamicRunner.run is CPU/file-I/O-heavy async work called from @MainActor without @concurrent annotation, violating the Swift concurrent annotation rule. Add @concurrent to ConfiguredMenuBarDynamicRunner.run at line 85: change 'static func run(...) async' to 'static func run(...) @concurrent async'
Cmux Swift File And Package Boundaries ❌ Error 1004-line new file mixes subprocess and UI code (exceeds 800 threshold). CmuxConfig adds 335 lines to 2917-line file (exceeds 250-line limit). No package boundary for independent logic. Extract subprocess execution to SwiftPM package. Reduce oversized file growth in CmuxConfig. Move menu resolution logic to separate module.
Cmux Architecture Rethink ❌ Error Calls cancel() on a suspended DispatchSourceTimer. If process.run() throws before timer.resume(), finish() cancels the timer in invalid state, causing undefined behavior. Move timer scheduling/resumption before process.run() to ensure timer is always running before any cancel() call.
Docstring Coverage ⚠️ Warning Docstring coverage is 1.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (9 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add configurable menu bar from cmux.json' is concise, specific, and directly reflects the main change—adding a new feature for configurable menu bars via cmux.json configuration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux No Hacky Sleeps ✅ Passed PR has no TypeScript/JavaScript/shell runtime changes in scope. All changes are Swift (covered by separate check), JSON schema, documentation rendering, or project files. No hacky sleeps introduced.
Cmux Swift Concurrency ✅ Passed Async patterns conform to allowed exceptions: background queues for OS APIs, DispatchSource OS boundaries, minimal main queue async, stored Tasks with lifecycle, @Published continues existing pattern.
Cmux Swift Logging ✅ Passed No logging violations found. PR adds no forbidden logging functions to app code. Stdout/stderr capture for dynamic menus is legitimate. No secrets or MainActor Logger issues.
Cmux Swiftui State Layout ✅ Passed No SwiftUI state violations. Menubar properties added to existing ObservableObject. ConfiguredMenuBarController is AppKit NSObject-based, not SwiftUI state.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds configurable menu bar via NSMenu/NSMenuItem manipulation. ConfiguredMenuBarController uses NSWindow only as weak references. Only window creation is test-only debug fixture in #if DEBUG block.
Description check ✅ Passed The pull request description comprehensively covers all required sections: Summary clearly states what changed and why, Testing section includes multiple verification commands, demo video (with reasonable explanation for why not recorded), Review Trigger provided, and Checklist fully completed with all items marked.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-configurable-menubar

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61a1e79ce6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/AppDelegate.swift Outdated
Comment on lines +6254 to +6256
guard let store = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow)?.cmuxConfigStore else {
return []
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep custom menus available when no main window exists

When all main windows are closed (a normal state in this app, especially with menu-bar-only usage), configuredMenuBarMenusForCommands returns [] because it requires an existing window context. The next refresh path removes previously injected top-level custom menus and then exits early, so global ui.menuBar commands become inaccessible until a new main window is created through some other entrypoint. This regresses menu-bar command availability in no-window sessions.

Useful? React with 👍 / 👎.

@lawrencecchen
lawrencecchen force-pushed the feat-configurable-menubar branch from 61a1e79 to d5cb1fe Compare May 13, 2026 04:13
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmuxTests/CmuxConfigContextMenuTests.swift`:
- Around line 587-589: The test asserts that a missing menuBar action yields
kind .newWorkspaceActionNotFound which is confusing; update the issue-kind to be
clearer by either renaming the enum case to .actionNotFound (if shared across
contexts) or adding a specific case .menuBarActionNotFound and use that in the
code path that emits configuration issues for menu bar items (search for where
configuration issues are created — e.g., the code that sets
store.configurationIssues, the factory/validator that currently emits
.newWorkspaceActionNotFound — then replace the emitted kind and update this test
to expect the new enum case).

In `@Sources/CmuxConfigUI.swift`:
- Around line 271-281: The decoder currently treats any object with .items as a
submenu which allows mixed objects like { "items": [], "action": "newTerminal" }
to be silently accepted; update the initializer decoding logic (the init(from
decoder:) that uses Self.trimmedString, CodingKeys, and constructs .submenu via
CmuxConfigMenuDefinition or .action via CmuxConfigMenuBarActionItem) to detect
when both submenu keys (items) and action keys are present and throw a
DecodingError.dataCorrupted (with a clear message) instead of choosing .submenu,
otherwise proceed as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 215367c6-c5e5-48e6-acf0-5d6b252bca1c

📥 Commits

Reviewing files that changed from the base of the PR and between be769af and 61a1e79.

📒 Files selected for processing (6)
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigUI.swift
  • cmuxTests/CmuxConfigContextMenuTests.swift
  • web/app/[locale]/docs/configuration/page.tsx
  • web/data/cmux.schema.json

Comment on lines +587 to +589
XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceActionNotFound)
XCTAssertEqual(store.configurationIssues.first?.settingName, "ui.menuBar.menus[0].items[0]")
XCTAssertEqual(store.configurationIssues.first?.commandName, "missing-action")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider a more specific or generic issue kind for menuBar validation.

The test validates that missing menuBar actions produce a .newWorkspaceActionNotFound issue, but the "newWorkspace" prefix is misleading since the setting path correctly identifies this as ui.menuBar.menus[0].items[0].

If the same issue kind is intentionally reused across different UI contexts, consider renaming it to something generic like .actionNotFound. Otherwise, introduce a dedicated .menuBarActionNotFound kind for clarity when debugging configuration issues.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmuxTests/CmuxConfigContextMenuTests.swift` around lines 587 - 589, The test
asserts that a missing menuBar action yields kind .newWorkspaceActionNotFound
which is confusing; update the issue-kind to be clearer by either renaming the
enum case to .actionNotFound (if shared across contexts) or adding a specific
case .menuBarActionNotFound and use that in the code path that emits
configuration issues for menu bar items (search for where configuration issues
are created — e.g., the code that sets store.configurationIssues, the
factory/validator that currently emits .newWorkspaceActionNotFound — then
replace the emitted kind and update this test to expect the new enum case).

Comment thread Sources/CmuxConfigUI.swift
@greptile-apps

greptile-apps Bot commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a fully configurable macOS menu bar driven by ui.menuBar in cmux.json, introducing top-level menus, menu extensions, submenu nesting, action refs, inline commands, separators, shortcut binding, and dynamic bash-backed sources with four refresh modes (onOpen, manual, onConfigReload, interval). A new ConfiguredMenuBarController (1 129 lines) manages the full lifecycle: rebuilding the menu bar on config or window changes, running dynamic sources as sandboxed subprocesses with timeout/output limits, caching last-good results, surfacing failures through notifications, and exposing activity to Task Manager.

  • Config resolution (CmuxConfig.swift, CmuxConfigUI.swift): New model hierarchy with Codable, validation, and resolvedMenuBarMenus plumbed into CmuxConfigStore; shortcutActions() extended to de-duplicate menu-bar action shortcuts.
  • Runtime controller (ConfiguredMenuBarController.swift): ConfiguredMenuBarOutputCollector + ConfiguredMenuBarDynamicRunner handle subprocess I/O; ConfiguredMenuBarController (@MainActor) manages the NSMenu hierarchy, dynamic-source state machines, interval timers, and trust gating.
  • Trust & execution (CmuxConfigExecutor.swift): New authorizeDynamicMenuSourceIfNeeded / isTrustedDynamicMenuSource helpers gate all dynamic source executions behind the existing project-action trust model.

Confidence Score: 3/5

The dynamic-source subprocess is not terminated when its owning Swift Task is cancelled, the cooperative thread pool is held for the full user-configurable timeout per concurrent source, and valid resolved items are silently discarded whenever any single issue is present in a dynamic command's output.

Three independent defects compound in the dynamic-source execution path: orphaned subprocesses accumulate on rapid reloads, the cooperative thread pool can starve when multiple interval sources fire simultaneously with long timeouts, and any validation issue in a dynamic command's JSON output causes the entire result to be thrown away rather than partially rendered.

Sources/ConfiguredMenuBarController.swift needs the most attention: subprocess lifecycle, cooperative pool usage, GCD/MainActor hop patterns, the 1000+ line file scope, and the finishDynamicSource item-discard bug all live there.

Important Files Changed

Filename Overview
Sources/ConfiguredMenuBarController.swift New 1 129-line file mixing output collection, subprocess execution, NSMenu materialisation, and dynamic-source lifecycle state machine. Multiple previously-flagged issues: subprocess not terminated on Task cancellation, legacy GCD hops in a @mainactor class, DispatchSourceTimer interval timers, cooperative thread pool starvation, and valid resolved items silently dropped on any issue in finishDynamicSource.
Sources/CmuxConfig.swift Adds menuBarMenus/menuBarExtensions @published properties, resolvedMenuBarMenus resolution pipeline, de-duplicated menuBarShortcutActions, and config-change notification. Previously flagged: two NSLog calls in resolvedMenuBarAction error paths.
Sources/CmuxConfigUI.swift Adds ~600 lines of new Codable model hierarchy for menu-bar config. The CmuxMenuBarUIDefinition.init(from:) array-form decode path uses try? which swallows item-level errors and produces misleading fallback errors.
Sources/CmuxConfigExecutor.swift Adds authorizeDynamicMenuSourceIfNeeded and isTrustedDynamicMenuSource helpers that correctly reuse the existing authorizeProjectActionIfNeeded trust model.
Sources/AppDelegate.swift Wires ConfiguredMenuBarController into the app lifecycle. Previously flagged: one NSLog call interpolating a user-configured targetID.
web/data/cmux.schema.json Adds JSON Schema for ui.menuBar. Previously flagged: the icon property is absent from the menuBarItem schema object.
cmuxTests/CmuxConfigContextMenuTests.swift Adds 517 lines of unit tests covering decoding, resolution, dynamic-source validation, nesting, and error reporting for the new menu-bar config model.

Sequence Diagram

sequenceDiagram
    participant User
    participant AppDelegate
    participant CMBC as ConfiguredMenuBarController
    participant Runner as DynamicRunner
    participant Bash as /bin/bash
    participant Store as CmuxConfigStore

    AppDelegate->>CMBC: installObserversIfNeeded()
    Note over CMBC: Subscribes to config/window/menu notifications

    Store-->>CMBC: cmuxConfigStoreDidChange (NotificationCenter)
    CMBC->>CMBC: scheduleRefresh() [GCD hop]
    CMBC->>AppDelegate: configuredMenuBarRuntimeContext()
    AppDelegate-->>CMBC: menus + extensions + configStore
    CMBC->>CMBC: removeConfiguredItems() rebuild NSMenu hierarchy

    alt Dynamic source present
        CMBC->>CMBC: dynamicSourceMenuItem() registers DynamicMenuDelegate
        opt "refresh == .interval"
            CMBC->>CMBC: scheduleIntervalDynamicSourcesIfNeeded() DispatchSourceTimer
        end
    end

    User->>CMBC: menuWillOpen (DynamicMenuDelegate)
    CMBC->>CmuxConfigExecutor: authorizeDynamicMenuSourceIfNeeded()
    CmuxConfigExecutor-->>CMBC: onAuthorized(command)
    CMBC->>Runner: run(command, cwd, timeout)
    Runner->>Bash: Process.run()
    Bash-->>Runner: stdout/stderr
    Runner-->>CMBC: ConfiguredMenuBarDynamicCommandResult

    alt stdout parses OK
        CMBC->>Store: resolveGeneratedMenuBarItems()
        Store-->>CMBC: resolved items
        CMBC->>CMBC: "state.phase = .loaded"
    else parse/resolve error
        CMBC->>AppDelegate: notifyConfiguredMenuBarDynamicFailure()
        CMBC->>CMBC: "state.phase = .failed"
    end
Loading

Reviews (17): Last reviewed commit: "Deduplicate menu bar shortcut routing" | Re-trigger Greptile

Comment thread Sources/CmuxConfig.swift Outdated
Comment on lines +2656 to +2676
NSLog("[CmuxConfig] %@", issue.logMessage)
return (nil, issue)
}
action = resolved
} else if let inlineAction = item.inlineAction {
let id = "cmux.menuBar." + Self.generatedMenuID(
title: [settingName, item.title].compactMap { $0 }.joined(separator: "."),
index: 0
)
guard let resolved = CmuxResolvedConfigAction.fromDefinition(
id: id,
definition: inlineAction,
sourcePath: settingSourcePath
) else {
let issue = CmuxConfigIssue(
kind: .newWorkspaceActionNotFound,
settingName: settingName,
commandName: item.title,
sourcePath: settingSourcePath
)
NSLog("[CmuxConfig] %@", issue.logMessage)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Production NSLog bypasses unified logging

Both resolvedMenuBarAction error paths use NSLog instead of the project's Logger-based unified logging. NSLog is not addressed by any of the allowed exceptions (not in Sources/Providers/, not #if DEBUG guarded, and these are new additions — not pre-existing code moved by this PR). The issue logMessage may also include action identifiers that could leak user-configured command names in system crash logs.

Use the existing nonisolated private let logger = Logger(subsystem: Logging.subsystem, category: "CmuxConfig") pattern and call logger.warning(...) or logger.error(...) for these paths, consistent with how the rest of the codebase handles diagnostic output.

Rule Used: Flag production Swift diagnostics that bypass unif... (source)

Comment thread web/data/cmux.schema.json
Comment on lines +930 to +937
"tooltip": {
"type": "string",
"description": "Optional tooltip metadata."
}
}
}
]
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The icon property is supported by CmuxConfigMenuBarActionItem (decoded and rendered as a menu-item image) but is absent from the menuBarItem schema object. Because additionalProperties: true, schema validation won't fail, but editors and docs won't surface icon as an available key. Users relying on schema-driven autocomplete will have no guidance for it.

Suggested change
"tooltip": {
"type": "string",
"description": "Optional tooltip metadata."
}
}
}
]
},
"tooltip": {
"type": "string",
"description": "Optional tooltip metadata."
},
"icon": {
"type": "string",
"description": "Optional SF Symbol name shown as the menu-item image (e.g. \"play.fill\")."
}
}
}
]
},

Comment thread Sources/CmuxConfig.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 6253-6398: Move all configured menu-bar logic out of AppDelegate
into a new ConfiguredMenuBarController class: create ConfiguredMenuBarController
and copy the private functions and state that begin with configuredMenuBar* plus
configuredMenuBarMenusForCommands(_:),
performConfiguredMenuBarAction(_:preferredWindow:),
configuredMenuBarImage(for:), configuredMenuBarMenu(from:),
performConfiguredMenuBarMenuItem(_:),
installConfiguredMenuBarObserversIfNeeded(), scheduleConfiguredMenuBarRefresh(),
refreshConfiguredMenuBar(), configuredMenuBarInsertionIndex(in:), and
createMainWindowContextForConfiguredMenuBarAction() and the arrays
configuredMenuBarTopLevelItems/configuredMenuBarActionBoxes/configuredMenuBarObserverTokens/configuredMenuBarRefreshScheduled
into it; make them instance members and resolve dependencies by injecting
required collaborators from AppDelegate (e.g., a CmuxConfigStore provider /
configured menus accessor, mainWindowContexts or a resolver function
preferredRegisteredMainWindowContext(preferredWindow:), createMainWindow(),
resolvedWindow(for:), executeConfiguredCmuxAction(_:context:preferredWindow:)),
and NSApp/main menu access; update the `@objc` selector
performConfiguredMenuBarMenuItem(_:)'s target to the new controller; in
AppDelegate replace the copied implementations with a single stored instance of
ConfiguredMenuBarController, instantiate it during setup, and forward or pass
injected closures/properties as needed so AppDelegate becomes wiring-only.

In `@Sources/CmuxConfigUI.swift`:
- Around line 207-218: The encode(to:) currently only serializes inlineAction
when action is nil, which drops the outer overrides (title/icon/tooltip) for
CmuxConfigMenuBarActionItem; update encode(to encoder: Encoder) so that when
action is nil but inlineAction is present you still create a keyed container
(keyedBy: CodingKeys.self), encode the inlineAction into the appropriate key
(using either container.encode(inlineAction, forKey: .inlineAction) or
container.superEncoder(forKey: .inlineAction) then inlineAction.encode(to:)),
and also encodeIfPresent title, icon, and tooltip into the same container so the
outer overrides are preserved on round-trip; keep the existing branch that
encodes action unchanged.

In `@web/data/cmux.schema.json`:
- Around line 41-48: The object-form schema for ui.menuBar currently allows any
object because it doesn't require the "menus" property; update the schema block
that defines the object with "properties": { "menus": { "$ref":
"#/$defs/menuBarMenus" } } (the ui.menuBar object-form) to include a "required":
["menus"] entry so that an empty or unrelated object no longer validates as
ui.menuBar and the menus property is mandatory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 116658d0-78a3-4078-a565-085cba00a8d4

📥 Commits

Reviewing files that changed from the base of the PR and between 61a1e79 and d5cb1fe.

📒 Files selected for processing (6)
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigUI.swift
  • cmuxTests/CmuxConfigContextMenuTests.swift
  • web/app/[locale]/docs/configuration/page.tsx
  • web/data/cmux.schema.json

Comment thread Sources/AppDelegate.swift Outdated
Comment on lines +6253 to +6398
func configuredMenuBarMenusForCommands(preferredWindow: NSWindow? = nil) -> [CmuxResolvedMenuBarMenu] {
guard let store = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow)?.cmuxConfigStore else {
return []
}
return store.menuBarMenus
}

@discardableResult
func performConfiguredMenuBarAction(
_ action: CmuxResolvedConfigAction,
preferredWindow: NSWindow? = nil
) -> Bool {
let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow)
?? createMainWindowContextForConfiguredMenuBarAction()
guard let context else {
NSSound.beep()
return false
}
let preferredWindow = resolvedWindow(for: context) ?? preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
guard executeConfiguredCmuxAction(action, context: context, preferredWindow: preferredWindow) else {
NSSound.beep()
return false
}
return true
}

private func createMainWindowContextForConfiguredMenuBarAction() -> MainWindowContext? {
guard mainWindowContexts.isEmpty else {
return preferredMainWindowContextForWorkspaceCreation(debugSource: "menuBar.configured")
}
let windowId = createMainWindow()
return mainWindowContexts.values.first { $0.windowId == windowId }
}

private func installConfiguredMenuBarObserversIfNeeded() {
guard configuredMenuBarObserverTokens.isEmpty else { return }
let names: [Notification.Name] = [
.cmuxConfigStoreDidChange,
.mainWindowContextsDidChange,
NSWindow.didBecomeKeyNotification,
NSWindow.didBecomeMainNotification,
]
configuredMenuBarObserverTokens = names.map { name in
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in
self?.scheduleConfiguredMenuBarRefresh()
}
}
}
scheduleConfiguredMenuBarRefresh()
}

private func scheduleConfiguredMenuBarRefresh() {
guard !configuredMenuBarRefreshScheduled else { return }
configuredMenuBarRefreshScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.configuredMenuBarRefreshScheduled = false
self.refreshConfiguredMenuBar()
}
}

private func refreshConfiguredMenuBar() {
guard let mainMenu = NSApp.mainMenu else { return }

for item in configuredMenuBarTopLevelItems {
let index = mainMenu.index(of: item)
if index >= 0 {
mainMenu.removeItem(at: index)
}
}
configuredMenuBarTopLevelItems.removeAll()
configuredMenuBarActionBoxes.removeAll()

let menus = configuredMenuBarMenusForCommands(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
guard !menus.isEmpty else { return }

var insertionIndex = configuredMenuBarInsertionIndex(in: mainMenu)
for menu in menus {
let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: "")
item.submenu = configuredMenuBarMenu(from: menu)
mainMenu.insertItem(item, at: insertionIndex)
configuredMenuBarTopLevelItems.append(item)
insertionIndex += 1
}
}

private func configuredMenuBarInsertionIndex(in mainMenu: NSMenu) -> Int {
let notificationsTitle = String(localized: "menu.notifications.title", defaultValue: "Notifications")
if let notificationsIndex = mainMenu.items.lastIndex(where: { $0.title == notificationsTitle }) {
return notificationsIndex + 1
}
#if DEBUG
if let debugIndex = mainMenu.items.lastIndex(where: { $0.title == "Debug" }) {
return debugIndex
}
#endif
return min(mainMenu.items.count, max(1, mainMenu.items.count - 1))
}

private func configuredMenuBarMenu(from menu: CmuxResolvedMenuBarMenu) -> NSMenu {
let nsMenu = NSMenu(title: menu.title)
for item in menu.items {
switch item {
case .separator:
if !nsMenu.items.isEmpty, nsMenu.items.last?.isSeparatorItem == false {
nsMenu.addItem(.separator())
}
case .submenu(let submenu):
let item = NSMenuItem(title: submenu.title, action: nil, keyEquivalent: "")
item.submenu = configuredMenuBarMenu(from: submenu)
nsMenu.addItem(item)
case .action(let menuAction):
let item = NSMenuItem(
title: menuAction.title,
action: #selector(performConfiguredMenuBarMenuItem(_:)),
keyEquivalent: ""
)
item.target = self
let box = ConfiguredMenuBarActionBox(action: menuAction.action)
configuredMenuBarActionBoxes.append(box)
item.representedObject = box
item.toolTip = menuAction.tooltip
item.image = configuredMenuBarImage(for: menuAction.icon ?? menuAction.action.icon)
nsMenu.addItem(item)
}
}

while nsMenu.items.last?.isSeparatorItem == true {
nsMenu.removeItem(at: nsMenu.items.count - 1)
}
return nsMenu
}

private func configuredMenuBarImage(for icon: CmuxButtonIcon?) -> NSImage? {
guard case .some(.symbol(let symbolName)) = icon else { return nil }
return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
}

@objc private func performConfiguredMenuBarMenuItem(_ sender: NSMenuItem) {
guard let box = sender.representedObject as? ConfiguredMenuBarActionBox else {
NSSound.beep()
return
}
performConfiguredMenuBarAction(box.action, preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract configurable menu-bar construction/dispatch out of AppDelegate.

This block adds more UI menu rendering + action-routing responsibility to an already cross-cutting file. Please move this feature into a focused coordinator (e.g., ConfiguredMenuBarController) and keep AppDelegate as wiring-only.

As per coding guidelines: "Flag Swift files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppDelegate.swift` around lines 6253 - 6398, Move all configured
menu-bar logic out of AppDelegate into a new ConfiguredMenuBarController class:
create ConfiguredMenuBarController and copy the private functions and state that
begin with configuredMenuBar* plus configuredMenuBarMenusForCommands(_:),
performConfiguredMenuBarAction(_:preferredWindow:),
configuredMenuBarImage(for:), configuredMenuBarMenu(from:),
performConfiguredMenuBarMenuItem(_:),
installConfiguredMenuBarObserversIfNeeded(), scheduleConfiguredMenuBarRefresh(),
refreshConfiguredMenuBar(), configuredMenuBarInsertionIndex(in:), and
createMainWindowContextForConfiguredMenuBarAction() and the arrays
configuredMenuBarTopLevelItems/configuredMenuBarActionBoxes/configuredMenuBarObserverTokens/configuredMenuBarRefreshScheduled
into it; make them instance members and resolve dependencies by injecting
required collaborators from AppDelegate (e.g., a CmuxConfigStore provider /
configured menus accessor, mainWindowContexts or a resolver function
preferredRegisteredMainWindowContext(preferredWindow:), createMainWindow(),
resolvedWindow(for:), executeConfiguredCmuxAction(_:context:preferredWindow:)),
and NSApp/main menu access; update the `@objc` selector
performConfiguredMenuBarMenuItem(_:)'s target to the new controller; in
AppDelegate replace the copied implementations with a single stored instance of
ConfiguredMenuBarController, instantiate it during setup, and forward or pass
injected closures/properties as needed so AppDelegate becomes wiring-only.

Comment thread Sources/CmuxConfigUI.swift
Comment thread web/data/cmux.schema.json
Comment on lines +41 to +48
"type": "object",
"additionalProperties": true,
"properties": {
"menus": {
"$ref": "#/$defs/menuBarMenus"
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Require menus in object-form ui.menuBar schema.

Right now {} (and any unrelated object) validates as ui.menuBar, which weakens config validation and can hide user mistakes.

Suggested schema fix
            {
              "type": "object",
              "additionalProperties": true,
+             "required": ["menus"],
              "properties": {
                "menus": {
                  "$ref": "#/$defs/menuBarMenus"
                }
              }
            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"type": "object",
"additionalProperties": true,
"properties": {
"menus": {
"$ref": "#/$defs/menuBarMenus"
}
}
}
"type": "object",
"additionalProperties": true,
"required": ["menus"],
"properties": {
"menus": {
"$ref": "#/$defs/menuBarMenus"
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/data/cmux.schema.json` around lines 41 - 48, The object-form schema for
ui.menuBar currently allows any object because it doesn't require the "menus"
property; update the schema block that defines the object with "properties": {
"menus": { "$ref": "#/$defs/menuBarMenus" } } (the ui.menuBar object-form) to
include a "required": ["menus"] entry so that an empty or unrelated object no
longer validates as ui.menuBar and the menus property is mandatory.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.swift Outdated
mainMenu: mainMenu,
customMenusByConfigID: customMenusByConfigID
) else {
NSLog("[MenuBarConfig] Skipping extension for missing menu target %@", menuExtension.targetID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Production NSLog bypasses unified logging

NSLog("[MenuBarConfig] Skipping extension for missing menu target %@", menuExtension.targetID) writes directly to the system log as unstructured text, bypassing the project's Logger-based unified logging. It also interpolates a user-configured target ID into the message, which ends up in Console.app and crash reports. Use the existing nonisolated private let logger = Logger(subsystem: Logging.subsystem, category: "AppDelegate") pattern and call logger.warning(...) with the target ID at .private sensitivity if it could contain user data.

Rule Used: Flag production Swift diagnostics that bypass unif... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/CmuxConfig.swift (1)

1740-1769: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the menu-bar resolver out of CmuxConfigStore.

This file is already over 3k lines, and this PR adds another large subsystem for menu-bar grouping, resolution, issue building, and publication on top of parsing and file-watching. That makes the store harder to test in isolation and breaches the repo’s large-file budget for existing production Swift files. As per coding guidelines: "do not accept more than 250 lines added to an existing production Swift file that is already over 800 lines, unless an extraction exception is met by removing/moving mixed responsibilities and decreasing total line count by more than 200 lines."

Also applies to: 1933-2806

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/CmuxConfig.swift` around lines 1740 - 1769, The CmuxConfigStore has
grown too large and the menu-bar resolution subsystem (types and logic around
menu-bar grouping, resolution, issue building and publication) must be extracted
into a separate resolver component: create a new file (e.g., MenuBarResolver)
and move the related structs and logic (MenuBarConfigGroup,
ResolvedSurfaceTabBarButtonEntry, ResolvedSurfaceTabBarButtons,
ResolvedContextMenuItems, ResolvedMenuBarMenus, ResolvedMenuBarItems and any
helper functions/methods that build/resolve menus or publish menu-bar state)
into that new type, refactor CmuxConfigStore to call into the new
MenuBarResolver API for parsing/watching results and issue aggregation, update
visibility/access control and dependency injection so tests can target
MenuBarResolver in isolation, and remove the moved code from CmuxConfigStore to
ensure the original file's total line count is reduced by at least ~200 lines.
♻️ Duplicate comments (3)
web/data/cmux.schema.json (1)

41-48: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Require menus in object-form ui.menuBar schema.

Right now {} (and any unrelated object) validates as ui.menuBar, which weakens config validation and can hide user mistakes.

🔧 Suggested schema fix
            {
              "type": "object",
              "additionalProperties": true,
+             "required": ["menus"],
              "properties": {
                "menus": {
                  "$ref": "#/$defs/menuBarMenus"
                }
              }
            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/data/cmux.schema.json` around lines 41 - 48, The schema for the
object-form ui.menuBar currently allows {} because "menus" is optional; update
the object schema (the definition that has "properties": { "menus": { "$ref":
"#/$defs/menuBarMenus" } }) to require the menus property by adding "required":
["menus"] so only objects containing the menus (as defined by
$defs/menuBarMenus) validate as ui.menuBar.
cmuxTests/CmuxConfigContextMenuTests.swift (1)

721-723: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Consider a more specific issue kind for menuBar validation.

The test validates that missing menuBar actions produce a .newWorkspaceActionNotFound issue, but the "newWorkspace" prefix is misleading since the setting path correctly identifies this as ui.menuBar.menus[0].items[0].

If the same issue kind is intentionally reused across different UI contexts, consider renaming it to something generic like .actionNotFound. Otherwise, introduce a dedicated .menuBarActionNotFound kind for clarity when debugging configuration issues.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmuxTests/CmuxConfigContextMenuTests.swift` around lines 721 - 723, The test
asserts the issue kind as .newWorkspaceActionNotFound for a missing menu bar
action; rename or replace that case with a clearer symbol and update usages:
either add a new enum case .menuBarActionNotFound and change the validator that
currently emits .newWorkspaceActionNotFound (and all references) to emit
.menuBarActionNotFound, or rename the case to a generic .actionNotFound and
update the test assertion (XCTAssertEqual(store.configurationIssues.first?.kind,
.menuBarActionNotFound or .actionNotFound)) and any code that constructs that
issue; ensure the settingName check ("ui.menuBar.menus[0].items[0]") remains
unchanged.
Sources/AppDelegate.swift (1)

6520-7197: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Same concern: configurable menu-bar rendering/dispatch keeps growing inside AppDelegate.

This adds another ~680 lines of menu construction, dynamic-source orchestration, subprocess execution wiring, task-manager payload shaping, and three new @objc action selectors directly to AppDelegate.swift, which is already a multi-thousand-line file mixing AppKit, window/workspace coordination, shortcut routing, and notifications. The previous reviewer feedback to extract a focused coordinator (e.g., ConfiguredMenuBarController) still applies and would let AppDelegate stay wiring-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppDelegate.swift` around lines 6520 - 7197, The AppDelegate has ~680
lines of menu-bar logic that should be extracted into a dedicated coordinator to
keep AppDelegate wiring-only; move all functions and state related to configured
menu bar rendering/dispatch (e.g., configuredMenuBarMenusForCommands,
configuredMenuBarExtensionsForCommands, performConfiguredMenuBarAction,
createMainWindowContextForConfiguredMenuBarAction,
installConfiguredMenuBarObserversIfNeeded, scheduleConfiguredMenuBarRefresh,
refreshConfiguredMenuBar, configuredMenuBarInsertionIndex,
configuredMenuBarMenu, configuredMenuBarMenuItems, configuredMenuBarImage,
configuredMenuBarDynamicSourceMenuItem, configuredMenuBarTargetMenu,
normalizedConfiguredMenuBarTargetID, runConfigReloadDynamicMenuSourcesIfNeeded,
scheduleIntervalDynamicMenuSourcesIfNeeded,
cancelRemovedConfiguredMenuBarDynamicTimers,
cancelConfiguredMenuBarDynamicTimer, configuredMenuBarDynamicMenuWillOpen,
renderConfiguredMenuBarDynamicSourceMenu, configuredMenuBarDisabledItem,
dynamicMenuEmptyTitle, configuredMenuBarAddSeparatorIfNeeded,
refreshConfiguredMenuBarDynamicSource, startConfiguredMenuBarDynamicSource,
finishConfiguredMenuBarDynamicSource, notifyConfiguredMenuBarDynamicFailure,
configuredDynamicMenuTaskManagerPayload, configuredDynamicMenuPhaseLabel,
performConfiguredMenuBarMenuItem, reloadConfiguredMenuBarDynamicSource,
copyConfiguredMenuBarDynamicSourceError and all associated stored
properties/state/boxes/delegates) into a new ConfiguredMenuBarController (or
similar) class; have AppDelegate instantiate and delegate observer registration,
menu refresh triggers, and the three `@objc` selectors to that controller, and
update callers to use controller methods (e.g., controller.refresh(),
controller.performAction(_:preferredWindow:),
controller.configuredMenusForCommands()). Ensure all references to
AppDelegate-only globals (tabManager, notificationStore, createMainWindow,
preferredRegisteredMainWindowContext, etc.) are passed via initializer or small
protocol so the controller is testable and AppDelegate remains a thin
coordinator.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 7124-7141: The code is formatting countable strings with
String(format:) which won't pluralize correctly; add ICU plural entries for
taskManager.dynamicMenu.items (and taskManager.dynamicMenu.duration if desired)
in Localizable.xcstrings with .one/.other forms, then change the call sites that
build detailParts (where generatedItemCount and durationMS are used) to use the
localized plural selection API (use the String(localized:..., count:) or
equivalent ICU-aware initializer) instead of wrapping String(localized: ...) in
String(format:), so the correct plural form is selected for each locale.
- Around line 142-244: The DispatchSource timer created by
DispatchSource.makeTimerSource(...) is suspended and may be released without
ever being resumed if process.run() throws; resume the timer before any early
return so the source is not released suspended. Update the flow around the try/
catch and timer usage (symbols: timer, finish(_:) and process.run()) to ensure
timer.resume() is called before calling finish or returning on error — either
resume the timer immediately after configuring it (before try process.run()) or
call timer.resume() in the catch path before invoking finish — so the suspended
source is always resumed prior to potential cancellation/release.

In `@Sources/CmuxConfig.swift`:
- Around line 2553-2608: The code can publish duplicate top-level menu configIDs
(generated by generatedMenuID and sanitized by sanitizeConfigText), causing
ambiguous extends routing; fix by tracking seen configIDs in the loop that
builds CmuxResolvedMenuBarMenu (the block that produces
menus/extensions/issues), and if a computed configID (configID) is already in
the seen set, append a CmuxConfigIssue(kind: .menuBarInvalidMenu, settingName:
menuSettingName, sourcePath: group.sourcePath, message: "duplicate menu id") and
skip adding the duplicate menu instead of publishing it; apply the same
seen-configID check and error emission in the other analogous menu-building
location (the block referenced around the other menu loop) so duplicates across
config groups are rejected consistently.

In `@Sources/TaskManagerSnapshot.swift`:
- Around line 82-84: The UI shows incorrect "1 sources" because the code uses a
simple format string for dynamicMenus count; update localization to use ICU
pluralization (provide keys like "taskManager.row.dynamicMenus.count.one" and
".other" or an ICU plural entry) and change the call in TaskManagerSnapshot
where dynamicMenus.count is formatted (the String(format: String(localized:
"taskManager.row.dynamicMenus.count", ...), dynamicMenus.count) usage) to use
the ICU-aware localization API (e.g., the localized plural lookup /
String.localizedStringWithFormat or Swift's String(localized:) with a pluralized
localization entry) so the singular and plural forms are rendered correctly
across locales.

---

Outside diff comments:
In `@Sources/CmuxConfig.swift`:
- Around line 1740-1769: The CmuxConfigStore has grown too large and the
menu-bar resolution subsystem (types and logic around menu-bar grouping,
resolution, issue building and publication) must be extracted into a separate
resolver component: create a new file (e.g., MenuBarResolver) and move the
related structs and logic (MenuBarConfigGroup, ResolvedSurfaceTabBarButtonEntry,
ResolvedSurfaceTabBarButtons, ResolvedContextMenuItems, ResolvedMenuBarMenus,
ResolvedMenuBarItems and any helper functions/methods that build/resolve menus
or publish menu-bar state) into that new type, refactor CmuxConfigStore to call
into the new MenuBarResolver API for parsing/watching results and issue
aggregation, update visibility/access control and dependency injection so tests
can target MenuBarResolver in isolation, and remove the moved code from
CmuxConfigStore to ensure the original file's total line count is reduced by at
least ~200 lines.

---

Duplicate comments:
In `@cmuxTests/CmuxConfigContextMenuTests.swift`:
- Around line 721-723: The test asserts the issue kind as
.newWorkspaceActionNotFound for a missing menu bar action; rename or replace
that case with a clearer symbol and update usages: either add a new enum case
.menuBarActionNotFound and change the validator that currently emits
.newWorkspaceActionNotFound (and all references) to emit .menuBarActionNotFound,
or rename the case to a generic .actionNotFound and update the test assertion
(XCTAssertEqual(store.configurationIssues.first?.kind, .menuBarActionNotFound or
.actionNotFound)) and any code that constructs that issue; ensure the
settingName check ("ui.menuBar.menus[0].items[0]") remains unchanged.

In `@Sources/AppDelegate.swift`:
- Around line 6520-7197: The AppDelegate has ~680 lines of menu-bar logic that
should be extracted into a dedicated coordinator to keep AppDelegate
wiring-only; move all functions and state related to configured menu bar
rendering/dispatch (e.g., configuredMenuBarMenusForCommands,
configuredMenuBarExtensionsForCommands, performConfiguredMenuBarAction,
createMainWindowContextForConfiguredMenuBarAction,
installConfiguredMenuBarObserversIfNeeded, scheduleConfiguredMenuBarRefresh,
refreshConfiguredMenuBar, configuredMenuBarInsertionIndex,
configuredMenuBarMenu, configuredMenuBarMenuItems, configuredMenuBarImage,
configuredMenuBarDynamicSourceMenuItem, configuredMenuBarTargetMenu,
normalizedConfiguredMenuBarTargetID, runConfigReloadDynamicMenuSourcesIfNeeded,
scheduleIntervalDynamicMenuSourcesIfNeeded,
cancelRemovedConfiguredMenuBarDynamicTimers,
cancelConfiguredMenuBarDynamicTimer, configuredMenuBarDynamicMenuWillOpen,
renderConfiguredMenuBarDynamicSourceMenu, configuredMenuBarDisabledItem,
dynamicMenuEmptyTitle, configuredMenuBarAddSeparatorIfNeeded,
refreshConfiguredMenuBarDynamicSource, startConfiguredMenuBarDynamicSource,
finishConfiguredMenuBarDynamicSource, notifyConfiguredMenuBarDynamicFailure,
configuredDynamicMenuTaskManagerPayload, configuredDynamicMenuPhaseLabel,
performConfiguredMenuBarMenuItem, reloadConfiguredMenuBarDynamicSource,
copyConfiguredMenuBarDynamicSourceError and all associated stored
properties/state/boxes/delegates) into a new ConfiguredMenuBarController (or
similar) class; have AppDelegate instantiate and delegate observer registration,
menu refresh triggers, and the three `@objc` selectors to that controller, and
update callers to use controller methods (e.g., controller.refresh(),
controller.performAction(_:preferredWindow:),
controller.configuredMenusForCommands()). Ensure all references to
AppDelegate-only globals (tabManager, notificationStore, createMainWindow,
preferredRegisteredMainWindowContext, etc.) are passed via initializer or small
protocol so the controller is testable and AppDelegate remains a thin
coordinator.

In `@web/data/cmux.schema.json`:
- Around line 41-48: The schema for the object-form ui.menuBar currently allows
{} because "menus" is optional; update the object schema (the definition that
has "properties": { "menus": { "$ref": "#/$defs/menuBarMenus" } }) to require
the menus property by adding "required": ["menus"] so only objects containing
the menus (as defined by $defs/menuBarMenus) validate as ui.menuBar.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c8ce583a-a1cc-4d8a-9fa2-e3ad98c0770f

📥 Commits

Reviewing files that changed from the base of the PR and between d5cb1fe and 9b33375.

📒 Files selected for processing (13)
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigExecutor.swift
  • Sources/CmuxConfigUI.swift
  • Sources/ContentView.swift
  • Sources/TaskManagerSnapshot.swift
  • Sources/TaskManagerTypes.swift
  • Sources/TerminalController.swift
  • Sources/TerminalControllerTopSupport.swift
  • cmuxTests/CmuxConfigContextMenuTests.swift
  • web/app/[locale]/docs/configuration/page.tsx
  • web/data/cmux.schema.json

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.swift Outdated
Comment on lines +7124 to +7141
if let generatedItemCount = state.generatedItemCount {
detailParts.append(String(
format: String(
localized: "taskManager.dynamicMenu.items",
defaultValue: "%d items"
),
generatedItemCount
))
}
if let durationMS = state.durationMS {
detailParts.append(String(
format: String(
localized: "taskManager.dynamicMenu.duration",
defaultValue: "%d ms"
),
durationMS
))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use ICU plural forms for the user-visible count strings.

"%d items" (and to a lesser extent "%d ms") is a countable noun that doesn't pluralize correctly across locales when emitted via String(format:). Define taskManager.dynamicMenu.items (and similar count-based strings used in user-facing menu/task-manager UI) with .one/.other plural variants in Localizable.xcstrings, and select via String(localized: ...) so each locale gets the right form.

Based on learnings: "Guideline: In Swift files (cmux project), when handling pluralized strings, prefer using localization keys with the ICU-style plural forms .one and .other ... ensures correct pluralization across locales and makes localization keys explicit."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppDelegate.swift` around lines 7124 - 7141, The code is formatting
countable strings with String(format:) which won't pluralize correctly; add ICU
plural entries for taskManager.dynamicMenu.items (and
taskManager.dynamicMenu.duration if desired) in Localizable.xcstrings with
.one/.other forms, then change the call sites that build detailParts (where
generatedItemCount and durationMS are used) to use the localized plural
selection API (use the String(localized:..., count:) or equivalent ICU-aware
initializer) instead of wrapping String(localized: ...) in String(format:), so
the correct plural form is selected for each locale.

Comment thread Sources/CmuxConfig.swift
Comment on lines +2553 to +2608
var menus: [CmuxResolvedMenuBarMenu] = []
var extensions: [CmuxResolvedMenuBarExtension] = []
var issues: [CmuxConfigIssue] = []

for (groupIndex, group) in configuredGroups.enumerated() {
for (index, menu) in group.menus.enumerated() {
let menuSettingName = "\(group.settingName)[\(index)]"
let menuIdentityName = "\(group.settingName).source\(groupIndex)[\(index)]"
let resolvedItems = resolvedMenuBarItems(
menu.items,
actions: actions,
commands: commands,
sourcePaths: sourcePaths,
settingName: "\(menuSettingName).items",
settingSourcePath: group.sourcePath,
identityName: "\(menuIdentityName).items"
)
issues.append(contentsOf: resolvedItems.issues)
guard !resolvedItems.items.isEmpty else { continue }

if let targetID = menu.extends {
let sanitizedTargetID = sanitizeConfigText(targetID, fallback: targetID)
extensions.append(
CmuxResolvedMenuBarExtension(
id: "\(menuIdentityName).extends.\(sanitizedTargetID)",
targetID: sanitizedTargetID,
items: resolvedItems.items
)
)
continue
}

guard let title = menu.title else {
issues.append(CmuxConfigIssue(
kind: .menuBarInvalidMenu,
settingName: menuSettingName,
sourcePath: group.sourcePath,
message: "menuBar menu must define title or extends"
))
continue
}

let fallbackID = menu.id ?? Self.generatedMenuID(title: title, index: index)
let configID = sanitizeConfigText(fallbackID, fallback: String(index))
menus.append(
CmuxResolvedMenuBarMenu(
id: "\(menuIdentityName).\(configID)",
configID: configID,
title: sanitizeConfigText(title, fallback: fallbackID),
items: resolvedItems.items
)
)
}
}

return ResolvedMenuBarMenus(menus: menus, extensions: extensions, issues: issues)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate duplicate top-level menu IDs before publishing them.

generatedMenuID(title:index) reuses the encoded title whenever it is non-empty, so two menus with the same title from different config groups resolve to the same configID. Because extends targets menus by that identifier, duplicates make extension routing ambiguous and can attach items to the wrong menu. Track seen configIDs here and surface a .menuBarInvalidMenu issue on duplicates instead of publishing both.

Also applies to: 2802-2806

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/CmuxConfig.swift` around lines 2553 - 2608, The code can publish
duplicate top-level menu configIDs (generated by generatedMenuID and sanitized
by sanitizeConfigText), causing ambiguous extends routing; fix by tracking seen
configIDs in the loop that builds CmuxResolvedMenuBarMenu (the block that
produces menus/extensions/issues), and if a computed configID (configID) is
already in the seen set, append a CmuxConfigIssue(kind: .menuBarInvalidMenu,
settingName: menuSettingName, sourcePath: group.sourcePath, message: "duplicate
menu id") and skip adding the duplicate menu instead of publishing it; apply the
same seen-configID check and error emission in the other analogous menu-building
location (the block referenced around the other menu loop) so duplicates across
config groups are rejected consistently.

Comment on lines +82 to +84
detail: String(
format: String(localized: "taskManager.row.dynamicMenus.count", defaultValue: "%d sources"),
dynamicMenus.count

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use ICU plural forms for dynamic menu count localization.

The current format string will produce "1 sources" for a single source. Based on learnings, pluralized strings should use separate localization keys with .one and .other suffixes to ensure correct pluralization across all locales.

🌐 Proposed fix using ICU plural forms
-            detail: String(
-                format: String(localized: "taskManager.row.dynamicMenus.count", defaultValue: "%d sources"),
-                dynamicMenus.count
-            ),
+            detail: dynamicMenus.count == 1
+                ? String(localized: "taskManager.row.dynamicMenus.count.one", defaultValue: "1 source")
+                : String(localized: "taskManager.row.dynamicMenus.count.other", defaultValue: "\(dynamicMenus.count) sources"),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
detail: String(
format: String(localized: "taskManager.row.dynamicMenus.count", defaultValue: "%d sources"),
dynamicMenus.count
detail: dynamicMenus.count == 1
? String(localized: "taskManager.row.dynamicMenus.count.one", defaultValue: "1 source")
: String(localized: "taskManager.row.dynamicMenus.count.other", defaultValue: "\(dynamicMenus.count) sources"),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/TaskManagerSnapshot.swift` around lines 82 - 84, The UI shows
incorrect "1 sources" because the code uses a simple format string for
dynamicMenus count; update localization to use ICU pluralization (provide keys
like "taskManager.row.dynamicMenus.count.one" and ".other" or an ICU plural
entry) and change the call in TaskManagerSnapshot where dynamicMenus.count is
formatted (the String(format: String(localized:
"taskManager.row.dynamicMenus.count", ...), dynamicMenus.count) usage) to use
the ICU-aware localization API (e.g., the localized plural lookup /
String.localizedStringWithFormat or Swift's String(localized:) with a pluralized
localization entry) so the singular and plural forms are rendered correctly
across locales.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Comment thread Sources/ConfiguredMenuBarController.swift
Comment thread Sources/ConfiguredMenuBarController.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/CmuxConfig.swift (1)

1740-1770: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract menu-bar configuration plumbing into a dedicated file.

Sources/CmuxConfig.swift is already ~3,250 lines and mixes config models, the SVG security inspector, CmuxConfigStore state ownership, file/directory watchers, JSONC parsing, and now all of menu-bar resolution. This PR adds ~337 lines of menu-bar-specific code (MenuBarConfigGroup, ResolvedMenuBarMenus, ResolvedMenuBarItems, resolvedMenuBarMenus, resolvedMenuBarItems, appendMenuBarSeparatorIfNeeded, resolvedMenuBarAction, resolveGeneratedMenuBarItems, generatedMenuID, plus the new published properties and loadAll() glue) to a file that is already well past the budgeted threshold for a coherent-responsibility file.

The resolution logic is self-contained (operates over actions, commands, sourcePaths plus sanitizeConfigText/canonicalActionID) and would fit naturally in something like Sources/CmuxConfigMenuBar.swift as an extension of CmuxConfigStore, keeping only the @Published storage, the loadAll() wiring, and MenuBarConfigGroup inside this file (or moving them too). That keeps this file from continuing to grow as the menu-bar surface area expands (icons, dynamic sources, extensions, etc.).

As per coding guidelines, "do not accept more than 250 lines added to an existing production Swift file that is already over 800 lines, unless an extraction exception is met by removing/moving mixed responsibilities and decreasing total line count by more than 200 lines" and "Flag Swift files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place".

Also applies to: 2087-2118, 2543-2723

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/CmuxConfig.swift` around lines 1740 - 1770, The file is too large and
the menu-bar resolution logic should be extracted into a new dedicated file;
move the menu-bar-specific types and functions (MenuBarConfigGroup,
ResolvedSurfaceTabBarButtonEntry, ResolvedSurfaceTabBarButtons,
ResolvedContextMenuItems, ResolvedMenuBarMenus, ResolvedMenuBarItems and the
functions resolvedMenuBarMenus, resolvedMenuBarItems,
appendMenuBarSeparatorIfNeeded, resolvedMenuBarAction,
resolveGeneratedMenuBarItems, generatedMenuID plus any helpers that operate only
on actions/commands/sourcePaths/sanitizeConfigText/canonicalActionID) into a new
Sources/CmuxConfigMenuBar.swift as an extension of CmuxConfigStore, preserving
access control and imports, and update references; keep only the `@Published`
storage and the loadAll() wiring in the original file (or move them too if you
prefer) so resolution logic is isolated and compilation/tests pass.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 2746-2775: The menu-bar error cases in the resolvedMenuBarAction
flow incorrectly use CmuxConfigIssue(kind: .newWorkspaceActionNotFound, ...) —
change both occurrences that construct CmuxConfigIssue in the branch that
handles item.action (using canonicalActionID/actionReference) and the branch
that builds an inline action via CmuxResolvedConfigAction.fromDefinition (using
item.title) to use .menuBarInvalidMenu instead of .newWorkspaceActionNotFound so
menu-bar problems are reported with the new menuBarInvalidMenu kind (keep the
same settingName, commandName, and sourcePath parameters).
- Around line 1971-1977: Clarify that ui.menuBar.menus uses composition (both
global and local contribute) by adding an inline comment next to the
MenuBarConfigGroup append and by updating the PR summary/docs; explicitly
mention that configuredMenuBarGroups.append(...) for ui.menuBar.menus
intentionally composes local menus with global menus (see
testResolvedMenuBarSupportsLocalAndGlobalMenus) rather than overriding like
newWorkspaceCommand/newWorkspace.action/newWorkspace.contextMenu/surfaceTabBarButtons
which check configuredNewWorkspaceActionID. Add a brief comment above the if-let
block referencing the composition semantics and note in the PR summary/docs that
menus are combined (local appended, global prepended) so readers aren’t misled
by “local-first, global-fallback.”

In `@Sources/CmuxConfigUI.swift`:
- Around line 92-109: Create a single reusable decoding helper by adding an
extension on KeyedDecodingContainer with two methods:
decodeTrimmedString(forKey:allowBlankAsNil:) -> String? and
decodeRequiredTrimmedString(forKey:) -> String that encapsulate the trimming,
blank-as-nil handling, and DecodingError.dataCorruptedError construction (use
key.stringValue in messages). Replace the duplicate private static
trimmedString/requiredTrimmedString helpers in types CmuxConfigButtonPlacement,
CmuxConfigContextMenuActionItem, CmuxConfigContextMenuItem (and any other files
noted) to call these new extension methods instead, keeping behavior and error
messages identical. Ensure the extension uses the container’s Key generic (Key:
CodingKey) so it works with each type’s CodingKeys and update callers to remove
the old private helpers.

In `@Sources/ConfiguredMenuBarController.swift`:
- Around line 691-696: The cancelDynamicTask currently cancels the Swift Task
and clears dynamicStates[sourceID].activeRunID/activePID but never signals the
underlying Process, leaving orphaned bash processes; update cancellation to
terminate the subprocess by sending a signal to the captured PID
(dynamicStates[sourceID].activePID) before clearing it (e.g., call kill(pid,
SIGTERM) or Process.terminate()), and/or modify
ConfiguredMenuBarDynamicRunner.run to wrap its withCheckedContinuation in
withTaskCancellationHandler so the cancellation handler calls
process.terminate() (or kills the PID) to ensure the subprocess is stopped when
dynamicTasks[sourceID]?.cancel() is invoked.
- Around line 1-982: The file mixes subprocess/output buffering logic with
AppKit UI/controller code; extract the subprocess pieces into a separate target
by moving ConfiguredMenuBarOutputCollector,
ConfiguredMenuBarDynamicCommandResult, and ConfiguredMenuBarDynamicRunner out of
ConfiguredMenuBarController into their own Swift source(s) in a new SwiftPM
target (e.g., CmuxSubprocess). Update the types' access levels to
public/internal as needed so ConfiguredMenuBarController can still call
ConfiguredMenuBarDynamicRunner.run and use
ConfiguredMenuBarDynamicCommandResult/ConfiguredMenuBarOutputCollector, add the
new package import where the controller references these symbols, and leave
ConfiguredMenuBarController (and all AppKit-dependent logic) in the existing app
module. Ensure you preserve behavior: keep the same APIs (function signatures,
constants like defaultTimeoutSeconds and outputLimitBytes) and tests can be
added against the new package.
- Around line 220-230: The timeout handler currently calls process.terminate()
(see timer.setEventHandler, finishLock, didFinish, didTimeOut) which only
SIGTERM's the bash parent and can leave child processes running; replace this by
spawning the command as a new process group using POSIX APIs (import Darwin,
create posix_spawnattr_t with POSIX_SPAWN_SETPGROUP and call
posix_spawnattr_setpgroup(attr, 0) so the child becomes a group leader) and
store the child PID, then on timeout send the signal to the whole group (use
kill(-childPid, SIGTERM) and optionally SIGKILL after a grace period) instead of
calling process.terminate(); ensure cleanup paths (cancel/normal finish) also
signal the group and avoid races with finishLock/didFinish/didTimeOut.

In `@web/data/cmux.schema.json`:
- Around line 996-1012: Add a JSON Schema conditional so intervalSeconds is
required when refresh equals "interval": modify the schema block containing the
"refresh" and "intervalSeconds" properties to include an if/then (or allOf with
if/then) that checks {"properties":{"refresh":{"const":"interval"}}} and in the
then clause adds "required":["intervalSeconds"] and optionally constraints on
intervalSeconds; follow the same pattern used for sessionIdSource to locate
where to insert the conditional so the validator rejects objects missing
intervalSeconds when refresh is "interval".

---

Outside diff comments:
In `@Sources/CmuxConfig.swift`:
- Around line 1740-1770: The file is too large and the menu-bar resolution logic
should be extracted into a new dedicated file; move the menu-bar-specific types
and functions (MenuBarConfigGroup, ResolvedSurfaceTabBarButtonEntry,
ResolvedSurfaceTabBarButtons, ResolvedContextMenuItems, ResolvedMenuBarMenus,
ResolvedMenuBarItems and the functions resolvedMenuBarMenus,
resolvedMenuBarItems, appendMenuBarSeparatorIfNeeded, resolvedMenuBarAction,
resolveGeneratedMenuBarItems, generatedMenuID plus any helpers that operate only
on actions/commands/sourcePaths/sanitizeConfigText/canonicalActionID) into a new
Sources/CmuxConfigMenuBar.swift as an extension of CmuxConfigStore, preserving
access control and imports, and update references; keep only the `@Published`
storage and the loadAll() wiring in the original file (or move them too if you
prefer) so resolution logic is isolated and compilation/tests pass.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 550b4308-b77e-4ba7-9985-63202a264cc7

📥 Commits

Reviewing files that changed from the base of the PR and between 9b33375 and 21798fb.

📒 Files selected for processing (7)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigUI.swift
  • Sources/ConfiguredMenuBarController.swift
  • cmuxTests/CmuxConfigContextMenuTests.swift
  • web/data/cmux.schema.json

Comment thread Sources/CmuxConfig.swift
Comment thread Sources/CmuxConfig.swift
Comment on lines +92 to +109
private static func trimmedString(
forKey key: CodingKeys,
in container: KeyedDecodingContainer<CodingKeys>,
allowBlankAsNil: Bool = false
) throws -> String? {
guard container.contains(key) else { return nil }
let raw = try container.decode(String.self, forKey: key)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
if allowBlankAsNil { return nil }
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "\(key.stringValue) must not be blank"
)
}
return trimmed
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consolidate the duplicated trimmedString/requiredTrimmedString helpers.

Each new type re-implements its own private trimmedString(forKey:in:allowBlankAsNil:) (and several also requiredTrimmedString) keyed by its own CodingKeys. The logic is identical and there are already similar copies in CmuxConfigButtonPlacement, CmuxConfigContextMenuActionItem, and CmuxConfigContextMenuItem. A single generic helper

extension KeyedDecodingContainer {
    func decodeTrimmedString(forKey key: Key, allowBlankAsNil: Bool = false) throws -> String? { ... }
    func decodeRequiredTrimmedString(forKey key: Key) throws -> String { ... }
}

would remove ~5 redundant copies and keep the error messages consistent.

Also applies to: 187-220, 261-294, 421-438, 505-520

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/CmuxConfigUI.swift` around lines 92 - 109, Create a single reusable
decoding helper by adding an extension on KeyedDecodingContainer with two
methods: decodeTrimmedString(forKey:allowBlankAsNil:) -> String? and
decodeRequiredTrimmedString(forKey:) -> String that encapsulate the trimming,
blank-as-nil handling, and DecodingError.dataCorruptedError construction (use
key.stringValue in messages). Replace the duplicate private static
trimmedString/requiredTrimmedString helpers in types CmuxConfigButtonPlacement,
CmuxConfigContextMenuActionItem, CmuxConfigContextMenuItem (and any other files
noted) to call these new extension methods instead, keeping behavior and error
messages identical. Ensure the extension uses the container’s Key generic (Key:
CodingKey) so it works with each type’s CodingKeys and update callers to remove
the old private helpers.

Comment on lines +1 to +982
import AppKit
import Foundation

struct ConfiguredMenuBarRuntimeContext {
var menus: [CmuxResolvedMenuBarMenu]
var extensions: [CmuxResolvedMenuBarExtension]
var configStore: CmuxConfigStore?
var workingDirectory: String
}

private enum ConfiguredMenuBarDynamicPhase: String, Sendable {
case idle
case running
case loaded
case failed
}

private struct ConfiguredMenuBarDynamicState: Sendable {
var phase: ConfiguredMenuBarDynamicPhase = .idle
var items: [CmuxResolvedMenuBarItem] = []
var error: String?
var lastRunAt: Date?
var durationMS: Int?
var exitStatus: Int32?
var activePID: Int32?
var activeRunID: UUID?
var generatedItemCount: Int?
var command: String?
var lastConfigRevisionRun: UInt64?
}

private struct ConfiguredMenuBarDynamicCommandResult: Sendable {
var stdout: String
var stderr: String
var exitStatus: Int32?
var durationMS: Int
var errorMessage: String?
}

private final class ConfiguredMenuBarOutputCollector: @unchecked Sendable {
private let lock = NSLock()
private let limit: Int
private var data = Data()
private var exceededLimit = false

init(limit: Int) {
self.limit = limit
}

func append(_ chunk: Data) {
guard !chunk.isEmpty else { return }
lock.lock()
defer { lock.unlock() }
if data.count + chunk.count > limit {
exceededLimit = true
let remaining = max(0, limit - data.count)
if remaining > 0 {
data.append(chunk.prefix(remaining))
}
return
}
data.append(chunk)
}

func string() -> String {
lock.lock()
let snapshot = data
lock.unlock()
return String(data: snapshot, encoding: .utf8)
?? String(decoding: snapshot, as: UTF8.self)
}

func didExceedLimit() -> Bool {
lock.lock()
let exceeded = exceededLimit
lock.unlock()
return exceeded
}
}

private enum ConfiguredMenuBarDynamicRunner {
static let defaultTimeoutSeconds: Double = 5
static let outputLimitBytes = 256 * 1024

static func run(
command: String,
cwd: String,
timeoutSeconds: Double,
onStarted: @escaping (Int32) -> Void
) async -> ConfiguredMenuBarDynamicCommandResult {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .utility).async {
runBlocking(
command: command,
cwd: cwd,
timeoutSeconds: timeoutSeconds,
onStarted: onStarted,
continuation: continuation
)
}
}
}

private static func runBlocking(
command: String,
cwd: String,
timeoutSeconds: Double,
onStarted: @escaping (Int32) -> Void,
continuation: CheckedContinuation<ConfiguredMenuBarDynamicCommandResult, Never>
) {
let startedAt = Date()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-lc", command]
process.currentDirectoryURL = URL(fileURLWithPath: cwd, isDirectory: true)

let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdout = ConfiguredMenuBarOutputCollector(limit: outputLimitBytes)
let stderr = ConfiguredMenuBarOutputCollector(limit: outputLimitBytes)
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
stdout.append(handle.availableData)
}
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
stderr.append(handle.availableData)
}
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

let finishLock = NSLock()
var didFinish = false
var didTimeOut = false
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))

func finish(_ result: ConfiguredMenuBarDynamicCommandResult) {
finishLock.lock()
guard !didFinish else {
finishLock.unlock()
return
}
didFinish = true
finishLock.unlock()
timer.cancel()
continuation.resume(returning: result)
}

process.terminationHandler = { terminatedProcess in
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
stdout.append(stdoutPipe.fileHandleForReading.readDataToEndOfFile())
stderr.append(stderrPipe.fileHandleForReading.readDataToEndOfFile())
let durationMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
finishLock.lock()
let timedOut = didTimeOut
finishLock.unlock()

let stdoutText = stdout.string()
let stderrText = stderr.string()
let errorMessage: String?
if timedOut {
errorMessage = String(
format: String(
localized: "menuBar.dynamic.error.timeout",
defaultValue: "Command timed out after %.1f seconds."
),
timeoutSeconds
)
} else if stdout.didExceedLimit() || stderr.didExceedLimit() {
errorMessage = String(
format: String(
localized: "menuBar.dynamic.error.outputLimit",
defaultValue: "Command output exceeded %d bytes."
),
outputLimitBytes
)
} else if terminatedProcess.terminationStatus != 0 {
let detail = stderrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? stdoutText.trimmingCharacters(in: .whitespacesAndNewlines)
: stderrText.trimmingCharacters(in: .whitespacesAndNewlines)
errorMessage = [
String(
format: String(
localized: "menuBar.dynamic.error.exitStatus",
defaultValue: "Command exited with status %d."
),
Int(terminatedProcess.terminationStatus)
),
detail
].filter { !$0.isEmpty }.joined(separator: "\n")
} else {
errorMessage = nil
}

finish(ConfiguredMenuBarDynamicCommandResult(
stdout: stdoutText,
stderr: stderrText,
exitStatus: terminatedProcess.terminationStatus,
durationMS: durationMS,
errorMessage: errorMessage
))
}

do {
try process.run()
onStarted(process.processIdentifier)
} catch {
let durationMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
finish(ConfiguredMenuBarDynamicCommandResult(
stdout: "",
stderr: "",
exitStatus: nil,
durationMS: durationMS,
errorMessage: error.localizedDescription
))
return
}

timer.schedule(deadline: .now() + max(0.1, timeoutSeconds))
timer.setEventHandler {
finishLock.lock()
guard !didFinish else {
finishLock.unlock()
return
}
didTimeOut = true
finishLock.unlock()
process.terminate()
}
timer.resume()
}
}

@MainActor
final class ConfiguredMenuBarController: NSObject {
private final class ActionBox: NSObject {
let action: CmuxResolvedConfigAction

init(action: CmuxResolvedConfigAction) {
self.action = action
}
}

private final class DynamicSourceBox: NSObject {
let sourceID: String

init(sourceID: String) {
self.sourceID = sourceID
}
}

private final class DynamicErrorBox: NSObject {
let error: String

init(error: String) {
self.error = error
}
}

private final class DynamicMenuDelegate: NSObject, NSMenuDelegate {
weak var owner: ConfiguredMenuBarController?
let sourceID: String
weak var preferredWindow: NSWindow?

init(owner: ConfiguredMenuBarController, sourceID: String, preferredWindow: NSWindow?) {
self.owner = owner
self.sourceID = sourceID
self.preferredWindow = preferredWindow
}

func menuWillOpen(_ menu: NSMenu) {
owner?.dynamicMenuWillOpen(sourceID: sourceID, preferredWindow: preferredWindow)
}
}

private enum DynamicRefreshReason {
case open
case manual
case configReload
case interval
}

private weak var owner: AppDelegate?
private let notificationCenter: NotificationCenter
private var observerTokens: [NSObjectProtocol] = []
private var topLevelItems: [NSMenuItem] = []
private var extensionItems: [NSMenuItem] = []
private var actionBoxes: [ActionBox] = []
private var dynamicSourceBoxes: [DynamicSourceBox] = []
private var dynamicErrorBoxes: [DynamicErrorBox] = []
private var menuDelegates: [DynamicMenuDelegate] = []
private var dynamicStates: [String: ConfiguredMenuBarDynamicState] = [:]
private var dynamicSourceByID: [String: CmuxResolvedMenuBarDynamicSource] = [:]
private var dynamicMenus: [String: NSMenu] = [:]
private var dynamicTimers: [String: DispatchSourceTimer] = [:]
private var dynamicTimerKeys: [String: String] = [:]
private var dynamicTasks: [String: Task<Void, Never>] = [:]
private var refreshScheduled = false

init(owner: AppDelegate, notificationCenter: NotificationCenter = .default) {
self.owner = owner
self.notificationCenter = notificationCenter
super.init()
}

deinit {
for token in observerTokens {
notificationCenter.removeObserver(token)
}
for timer in dynamicTimers.values {
timer.cancel()
}
for task in dynamicTasks.values {
task.cancel()
}
}

func installObserversIfNeeded() {
guard observerTokens.isEmpty else { return }
let names: [Notification.Name] = [
.cmuxConfigStoreDidChange,
.mainWindowContextsDidChange,
NSWindow.didBecomeKeyNotification,
NSWindow.didBecomeMainNotification,
]
observerTokens = names.map { name in
notificationCenter.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleRefresh()
}
}
}
scheduleRefresh()
}

func taskManagerPayload() -> [[String: Any]] {
dynamicSourceByID.values
.sorted { lhs, rhs in
if lhs.title == rhs.title { return lhs.id < rhs.id }
return lhs.title < rhs.title
}
.map { source in
let state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState()
var detailParts: [String] = [phaseLabel(state.phase)]
if let generatedItemCount = state.generatedItemCount {
detailParts.append(String(
format: String(
localized: "taskManager.dynamicMenu.items",
defaultValue: "%d items"
),
generatedItemCount
))
}
if let durationMS = state.durationMS {
detailParts.append(String(
format: String(
localized: "taskManager.dynamicMenu.duration",
defaultValue: "%d ms"
),
durationMS
))
}
let pids = state.activePID.map { [Int($0)] } ?? []
return [
"id": source.id,
"title": source.title,
"detail": detailParts.joined(separator: " / "),
"state": state.phase.rawValue,
"active_pid": state.activePID.map { Int($0) } as Any? ?? NSNull(),
"root_pids": pids,
"pids": pids,
"source_path": source.settingSourcePath as Any? ?? NSNull(),
"resources": CmuxTaskManagerResources.zeroPayload
]
}
}

private func scheduleRefresh() {
guard !refreshScheduled else { return }
refreshScheduled = true
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self else { return }
self.refreshScheduled = false
self.refresh()
}
}
}

private func refresh() {
guard let mainMenu = NSApp.mainMenu else { return }

removeConfiguredItems(from: mainMenu)

let preferredWindow = NSApp.keyWindow ?? NSApp.mainWindow
let runtime = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow)
let menus = runtime?.menus ?? []
let extensions = runtime?.extensions ?? []
guard !menus.isEmpty || !extensions.isEmpty else {
resetDynamicSources()
return
}

var insertionIndex = insertionIndex(in: mainMenu)
var customMenusByConfigID: [String: NSMenu] = [:]
for menu in menus {
let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: "")
let submenu = configuredMenu(from: menu, preferredWindow: preferredWindow)
item.submenu = submenu
mainMenu.insertItem(item, at: insertionIndex)
topLevelItems.append(item)
customMenusByConfigID[menu.configID] = submenu
insertionIndex += 1
}

for menuExtension in extensions {
guard let targetMenu = targetMenu(
for: menuExtension.targetID,
mainMenu: mainMenu,
customMenusByConfigID: customMenusByConfigID
) else {
continue
}
let items = menuItems(from: menuExtension.items, preferredWindow: preferredWindow)
guard !items.isEmpty else { continue }
if !targetMenu.items.isEmpty, items.first?.isSeparatorItem == false {
let separator = NSMenuItem.separator()
targetMenu.addItem(separator)
extensionItems.append(separator)
}
for item in items {
targetMenu.addItem(item)
extensionItems.append(item)
}
}

dynamicStates = dynamicStates.filter {
dynamicSourceByID[$0.key] != nil
}
cancelRemovedDynamicTimers()
cancelRemovedDynamicTasks()
scheduleIntervalDynamicSourcesIfNeeded(store: runtime?.configStore, preferredWindow: preferredWindow)
runConfigReloadDynamicSourcesIfNeeded(store: runtime?.configStore, preferredWindow: preferredWindow)
}

private func removeConfiguredItems(from mainMenu: NSMenu) {
for item in extensionItems {
let menu = item.menu
let index = menu?.index(of: item) ?? -1
if let menu, index >= 0 {
menu.removeItem(at: index)
}
}
extensionItems.removeAll()

for item in topLevelItems {
let index = mainMenu.index(of: item)
if index >= 0 {
mainMenu.removeItem(at: index)
}
}
topLevelItems.removeAll()
actionBoxes.removeAll()
dynamicSourceBoxes.removeAll()
dynamicErrorBoxes.removeAll()
menuDelegates.removeAll()
dynamicSourceByID.removeAll()
dynamicMenus.removeAll()
}

private func resetDynamicSources() {
dynamicStates.removeAll()
for sourceID in Array(dynamicTimers.keys) {
cancelDynamicTimer(sourceID: sourceID)
}
for sourceID in Array(dynamicTasks.keys) {
cancelDynamicTask(sourceID: sourceID)
}
}

private func insertionIndex(in mainMenu: NSMenu) -> Int {
let notificationsTitle = String(localized: "menu.notifications.title", defaultValue: "Notifications")
if let notificationsIndex = mainMenu.items.lastIndex(where: { $0.title == notificationsTitle }) {
return notificationsIndex + 1
}
#if DEBUG
if let debugIndex = mainMenu.items.lastIndex(where: { $0.title == "Debug" }) {
return debugIndex
}
#endif
return min(mainMenu.items.count, max(1, mainMenu.items.count - 1))
}

private func configuredMenu(
from menu: CmuxResolvedMenuBarMenu,
preferredWindow: NSWindow?
) -> NSMenu {
let nsMenu = NSMenu(title: menu.title)
for item in menuItems(from: menu.items, preferredWindow: preferredWindow) {
nsMenu.addItem(item)
}
return nsMenu
}

private func menuItems(
from items: [CmuxResolvedMenuBarItem],
preferredWindow: NSWindow?
) -> [NSMenuItem] {
var nsItems: [NSMenuItem] = []
for item in items {
switch item {
case .separator:
if !nsItems.isEmpty, nsItems.last?.isSeparatorItem == false {
nsItems.append(.separator())
}
case .submenu(let submenu):
let item = NSMenuItem(title: submenu.title, action: nil, keyEquivalent: "")
item.submenu = configuredMenu(from: submenu, preferredWindow: preferredWindow)
nsItems.append(item)
case .dynamicSource(let source):
nsItems.append(dynamicSourceMenuItem(for: source, preferredWindow: preferredWindow))
case .action(let menuAction):
let item = NSMenuItem(
title: menuAction.title,
action: #selector(performMenuItem(_:)),
keyEquivalent: ""
)
item.target = self
let box = ActionBox(action: menuAction.action)
actionBoxes.append(box)
item.representedObject = box
item.toolTip = menuAction.tooltip
item.image = menuImage(for: menuAction.icon ?? menuAction.action.icon)
nsItems.append(item)
}
}

while nsItems.last?.isSeparatorItem == true {
nsItems.removeLast()
}
return nsItems
}

private func menuImage(for icon: CmuxButtonIcon?) -> NSImage? {
guard case .some(.symbol(let symbolName)) = icon else { return nil }
return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
}

private func dynamicSourceMenuItem(
for source: CmuxResolvedMenuBarDynamicSource,
preferredWindow: NSWindow?
) -> NSMenuItem {
var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState()
if state.command != nil, state.command != source.source.command {
state = ConfiguredMenuBarDynamicState()
cancelDynamicTimer(sourceID: source.id)
cancelDynamicTask(sourceID: source.id)
}
state.command = source.source.command
dynamicStates[source.id] = state
dynamicSourceByID[source.id] = source

let item = NSMenuItem(title: source.title, action: nil, keyEquivalent: "")
item.image = menuImage(for: source.icon)
item.toolTip = source.tooltip
let submenu = NSMenu(title: source.title)
let delegate = DynamicMenuDelegate(owner: self, sourceID: source.id, preferredWindow: preferredWindow)
submenu.delegate = delegate
menuDelegates.append(delegate)
dynamicMenus[source.id] = submenu
item.submenu = submenu
renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow)
return item
}

private func targetMenu(
for targetID: String,
mainMenu: NSMenu,
customMenusByConfigID: [String: NSMenu]
) -> NSMenu? {
if let customMenu = customMenusByConfigID[targetID] {
return customMenu
}
let normalized = normalizedTargetID(targetID)
if let customMenu = customMenusByConfigID.first(where: {
normalizedTargetID($0.key) == normalized
})?.value {
return customMenu
}

let builtinTitles: [String: String] = [
"application": "",
"app": "",
"cmux": "",
"file": String(localized: "menu.file.title", defaultValue: "File"),
"edit": String(localized: "menu.edit.title", defaultValue: "Edit"),
"view": String(localized: "menu.view.title", defaultValue: "View"),
"notifications": String(localized: "menu.notifications.title", defaultValue: "Notifications"),
"window": String(localized: "menu.window.title", defaultValue: "Window"),
"help": String(localized: "menu.help.title", defaultValue: "Help"),
]
if ["application", "app", "cmux"].contains(normalized) {
return mainMenu.items.first?.submenu
}
guard let title = builtinTitles[normalized] else { return nil }
return mainMenu.items.first(where: { $0.title == title })?.submenu
}

private func normalizedTargetID(_ raw: String) -> String {
raw.lowercased().filter { $0.isLetter || $0.isNumber }
}

private func runConfigReloadDynamicSourcesIfNeeded(store: CmuxConfigStore?, preferredWindow: NSWindow?) {
guard let store else { return }
for source in dynamicSourceByID.values {
guard source.source.refresh == .onConfigReload else { continue }
let lastRevision = dynamicStates[source.id]?.lastConfigRevisionRun
guard lastRevision != store.configRevision else { continue }
dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].lastConfigRevisionRun = store.configRevision
refreshDynamicSource(sourceID: source.id, preferredWindow: preferredWindow, reason: .configReload)
}
}

private func scheduleIntervalDynamicSourcesIfNeeded(store: CmuxConfigStore?, preferredWindow: NSWindow?) {
guard let store else { return }
for source in dynamicSourceByID.values {
guard source.source.refresh == .interval,
let interval = source.source.intervalSeconds else {
cancelDynamicTimer(sourceID: source.id)
continue
}
let timerKey = [
source.source.command,
String(interval),
source.settingSourcePath ?? "",
].joined(separator: "\u{1F}")
guard dynamicTimerKeys[source.id] != timerKey else { continue }
cancelDynamicTimer(sourceID: source.id)

let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + interval, repeating: interval)
timer.setEventHandler { [weak self, weak store] in
MainActor.assumeIsolated {
guard let self, let store else { return }
guard let currentSource = self.dynamicSourceByID[source.id] else {
self.cancelDynamicTimer(sourceID: source.id)
return
}
guard CmuxConfigExecutor.isTrustedDynamicMenuSource(
command: currentSource.source.command,
sourceID: currentSource.id,
configSourcePath: currentSource.settingSourcePath,
globalConfigPath: store.globalConfigPath
) else {
return
}
self.refreshDynamicSource(
sourceID: currentSource.id,
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow,
reason: .interval
)
}
}
dynamicTimers[source.id] = timer
dynamicTimerKeys[source.id] = timerKey
timer.resume()
}
}

private func cancelRemovedDynamicTimers() {
let activeSourceIDs = Set(dynamicSourceByID.keys)
for sourceID in Array(dynamicTimers.keys) where !activeSourceIDs.contains(sourceID) {
cancelDynamicTimer(sourceID: sourceID)
}
}

private func cancelRemovedDynamicTasks() {
let activeSourceIDs = Set(dynamicSourceByID.keys)
for sourceID in Array(dynamicTasks.keys) where !activeSourceIDs.contains(sourceID) {
cancelDynamicTask(sourceID: sourceID)
}
}

private func cancelDynamicTimer(sourceID: String) {
dynamicTimers[sourceID]?.cancel()
dynamicTimers.removeValue(forKey: sourceID)
dynamicTimerKeys.removeValue(forKey: sourceID)
}

private func cancelDynamicTask(sourceID: String) {
dynamicTasks[sourceID]?.cancel()
dynamicTasks.removeValue(forKey: sourceID)
dynamicStates[sourceID]?.activeRunID = nil
dynamicStates[sourceID]?.activePID = nil
}

private func dynamicMenuWillOpen(sourceID: String, preferredWindow: NSWindow?) {
guard let source = dynamicSourceByID[sourceID] else { return }
renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow)
if (source.source.refresh ?? .onOpen) == .onOpen {
refreshDynamicSource(sourceID: sourceID, preferredWindow: preferredWindow, reason: .open)
}
}

private func renderDynamicSourceMenu(sourceID: String, preferredWindow: NSWindow?) {
guard let menu = dynamicMenus[sourceID],
let source = dynamicSourceByID[sourceID] else { return }
let state = dynamicStates[sourceID] ?? ConfiguredMenuBarDynamicState()
menu.removeAllItems()

let cachedItems = menuItems(from: state.items, preferredWindow: preferredWindow)
for item in cachedItems {
menu.addItem(item)
}
if cachedItems.isEmpty {
menu.addItem(disabledItem(title: dynamicMenuEmptyTitle(for: state)))
}

if state.phase == .running {
addSeparatorIfNeeded(to: menu)
menu.addItem(disabledItem(title: String(
localized: "menuBar.dynamic.running",
defaultValue: "Loading..."
)))
}

if let error = state.error, !error.isEmpty {
addSeparatorIfNeeded(to: menu)
menu.addItem(disabledItem(title: String(
localized: "menuBar.dynamic.failed",
defaultValue: "Dynamic menu failed"
)))
let copyItem = NSMenuItem(
title: String(localized: "menuBar.dynamic.copyError", defaultValue: "Copy Error"),
action: #selector(copyDynamicSourceError(_:)),
keyEquivalent: ""
)
copyItem.target = self
let box = DynamicErrorBox(error: error)
dynamicErrorBoxes.append(box)
copyItem.representedObject = box
menu.addItem(copyItem)
}

addSeparatorIfNeeded(to: menu)
let reloadTitle = state.phase == .idle && (source.source.refresh ?? .onOpen) == .manual
? String(localized: "menuBar.dynamic.load", defaultValue: "Load Dynamic Menu")
: String(localized: "menuBar.dynamic.reload", defaultValue: "Reload")
let reloadItem = NSMenuItem(
title: reloadTitle,
action: #selector(reloadDynamicSource(_:)),
keyEquivalent: ""
)
reloadItem.target = self
reloadItem.isEnabled = state.phase != .running
let box = DynamicSourceBox(sourceID: sourceID)
dynamicSourceBoxes.append(box)
reloadItem.representedObject = box
menu.addItem(reloadItem)
}

private func disabledItem(title: String) -> NSMenuItem {
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
item.isEnabled = false
return item
}

private func dynamicMenuEmptyTitle(for state: ConfiguredMenuBarDynamicState) -> String {
switch state.phase {
case .running:
return String(localized: "menuBar.dynamic.loading", defaultValue: "Loading...")
case .failed where state.items.isEmpty:
return String(localized: "menuBar.dynamic.noCachedItems", defaultValue: "No cached items")
case .loaded:
return String(localized: "menuBar.dynamic.noItems", defaultValue: "No items")
case .idle, .failed:
return String(localized: "menuBar.dynamic.notLoaded", defaultValue: "Not loaded")
}
}

private func addSeparatorIfNeeded(to menu: NSMenu) {
if !menu.items.isEmpty, menu.items.last?.isSeparatorItem == false {
menu.addItem(.separator())
}
}

private func refreshDynamicSource(
sourceID: String,
preferredWindow: NSWindow?,
reason: DynamicRefreshReason
) {
guard let source = dynamicSourceByID[sourceID] else { return }
guard dynamicStates[sourceID]?.phase != .running else { return }
guard let store = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow).configStore else { return }

let title = String(
format: String(
localized: "dialog.cmuxConfig.confirmDynamicMenu.title",
defaultValue: "Run Dynamic Menu Source: %@"
),
source.title
)
let authorized = CmuxConfigExecutor.authorizeDynamicMenuSourceIfNeeded(
command: source.source.command,
sourceID: source.id,
configSourcePath: source.settingSourcePath,
globalConfigPath: store.globalConfigPath,
displayTitle: title,
presentingWindow: preferredWindow
) { [weak self, weak store, weak preferredWindow] command in
guard let self, let store else { return }
self.startDynamicSource(
source,
command: command,
store: store,
preferredWindow: preferredWindow,
reason: reason
)
}
if !authorized {
var state = dynamicStates[sourceID] ?? ConfiguredMenuBarDynamicState()
state.phase = state.items.isEmpty ? .idle : .loaded
dynamicStates[sourceID] = state
renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow)
}
}

private func startDynamicSource(
_ source: CmuxResolvedMenuBarDynamicSource,
command: String,
store: CmuxConfigStore,
preferredWindow: NSWindow?,
reason: DynamicRefreshReason
) {
let runID = UUID()
var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState()
state.phase = .running
state.error = nil
state.command = command
state.lastRunAt = Date()
state.activeRunID = runID
state.activePID = nil
dynamicStates[source.id] = state
renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow)

let runtime = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow)
let workingDirectory = runtime?.workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path
let timeout = source.source.timeoutSeconds ?? ConfiguredMenuBarDynamicRunner.defaultTimeoutSeconds

cancelDynamicTask(sourceID: source.id)
dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].activeRunID = runID
dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].phase = .running

dynamicTasks[source.id] = Task { @MainActor [weak self, weak store, weak preferredWindow] in
let sourceID = source.id
let result = await ConfiguredMenuBarDynamicRunner.run(
command: command,
cwd: workingDirectory,
timeoutSeconds: timeout
) { [weak self, sourceID, runID, weak preferredWindow] pid in
DispatchQueue.main.async {
MainActor.assumeIsolated {
guard let self else { return }
guard self.dynamicStates[sourceID]?.activeRunID == runID else { return }
self.dynamicStates[sourceID, default: ConfiguredMenuBarDynamicState()].activePID = pid
self.renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow)
}
}
}
guard !Task.isCancelled else { return }
guard let self, let store else { return }
guard self.dynamicStates[sourceID]?.activeRunID == runID else { return }
self.finishDynamicSource(
source,
runID: runID,
result: result,
store: store,
preferredWindow: preferredWindow
)
self.dynamicTasks.removeValue(forKey: sourceID)
}
}

private func finishDynamicSource(
_ source: CmuxResolvedMenuBarDynamicSource,
runID: UUID,
result: ConfiguredMenuBarDynamicCommandResult,
store: CmuxConfigStore,
preferredWindow: NSWindow?
) {
guard dynamicStates[source.id]?.activeRunID == runID else { return }
var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState()
state.activeRunID = nil
state.activePID = nil
state.durationMS = result.durationMS
state.exitStatus = result.exitStatus

if let error = result.errorMessage {
state.phase = .failed
state.error = error
dynamicStates[source.id] = state
renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow)
owner?.notifyConfiguredMenuBarDynamicFailure(source: source, error: error, preferredWindow: preferredWindow)
return
}

do {
let data = Data(result.stdout.utf8)
let generatedItems = try JSONDecoder().decode([CmuxConfigMenuBarItem].self, from: data)
let resolved = store.resolveGeneratedMenuBarItems(
generatedItems,
settingName: "\(source.settingName).generated",
settingSourcePath: source.settingSourcePath
)
if let issue = resolved.issues.first {
throw NSError(domain: "CmuxDynamicMenu", code: 1, userInfo: [
NSLocalizedDescriptionKey: issue.logMessage
])
}
state.phase = .loaded
state.items = resolved.items
state.error = nil
state.generatedItemCount = resolved.items.count
} catch {
state.phase = .failed
state.error = error.localizedDescription
owner?.notifyConfiguredMenuBarDynamicFailure(
source: source,
error: error.localizedDescription,
preferredWindow: preferredWindow
)
}
dynamicStates[source.id] = state
renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow)
}

private func phaseLabel(_ phase: ConfiguredMenuBarDynamicPhase) -> String {
switch phase {
case .idle:
return String(localized: "taskManager.dynamicMenu.idle", defaultValue: "Idle")
case .running:
return String(localized: "taskManager.dynamicMenu.running", defaultValue: "Running")
case .loaded:
return String(localized: "taskManager.dynamicMenu.loaded", defaultValue: "Loaded")
case .failed:
return String(localized: "taskManager.dynamicMenu.failed", defaultValue: "Failed")
}
}

@objc private func performMenuItem(_ sender: NSMenuItem) {
guard let box = sender.representedObject as? ActionBox else {
NSSound.beep()
return
}
guard owner?.performConfiguredMenuBarAction(box.action, preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) == true else {
NSSound.beep()
return
}
}

@objc private func reloadDynamicSource(_ sender: NSMenuItem) {
guard let box = sender.representedObject as? DynamicSourceBox else {
NSSound.beep()
return
}
refreshDynamicSource(
sourceID: box.sourceID,
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow,
reason: .manual
)
}

@objc private func copyDynamicSourceError(_ sender: NSMenuItem) {
guard let box = sender.representedObject as? DynamicErrorBox else {
NSSound.beep()
return
}
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(box.error, forType: .string)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Split this 982-line file: extract runner/output-collector into a SwiftPM package.

This new production Swift file is 982 lines and mixes multiple distinct responsibilities in one place: subprocess execution (ConfiguredMenuBarDynamicRunner, lines 81–233), thread-safe stdout/stderr buffering (ConfiguredMenuBarOutputCollector, lines 40–79), AppKit NSMenu rendering and mutation (lines 405–575, 698–761), runtime dynamic-source state ownership, JSON parsing of generated items (lines 908–924), and Task Manager payload shaping (lines 337–377). Both the runner and the output collector are pure Foundation code that can compile and be unit-tested without AppKit, AppDelegate, or any cmux-app singletons, so they qualify for isolation in a small SwiftPM target where they can have their own fakes/fixtures.

Recommend, at minimum, splitting along these seams in this PR:

  • ConfiguredMenuBarOutputCollector → its own file (or a package target like CmuxSubprocess).
  • ConfiguredMenuBarDynamicRunner + ConfiguredMenuBarDynamicCommandResult → same package target; this is reusable subprocess infrastructure with no UI/state coupling.
  • Keep ConfiguredMenuBarController (AppKit/state/UI glue) in Sources/.

That brings the controller well under the 800-line ceiling and matches the rule that reusable, lifecycle-independent domain logic lives behind a SwiftPM target.

As per coding guidelines: "do not accept a new production Swift file over 400 lines without a clear single responsibility, or over 800 lines even when the responsibility is mostly coherent", "Flag Swift files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place", and "Flag features implemented directly in the app target/module root Sources/ path when their core logic is independent of cmux app lifecycle and can compile/test without AppKit, SwiftUI view state, Ghostty globals, or process-wide singletons".

🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 70-70: Prefer failable String(bytes:encoding:) initializer when converting Data to String

(optional_data_string_conversion)


[Warning] 40-40: Classes should have an explicit deinit method

(required_deinit)


[Warning] 237-237: Classes should have an explicit deinit method

(required_deinit)


[Warning] 245-245: Classes should have an explicit deinit method

(required_deinit)


[Warning] 253-253: Classes should have an explicit deinit method

(required_deinit)


[Warning] 261-261: Classes should have an explicit deinit method

(required_deinit)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfiguredMenuBarController.swift` around lines 1 - 982, The file
mixes subprocess/output buffering logic with AppKit UI/controller code; extract
the subprocess pieces into a separate target by moving
ConfiguredMenuBarOutputCollector, ConfiguredMenuBarDynamicCommandResult, and
ConfiguredMenuBarDynamicRunner out of ConfiguredMenuBarController into their own
Swift source(s) in a new SwiftPM target (e.g., CmuxSubprocess). Update the
types' access levels to public/internal as needed so ConfiguredMenuBarController
can still call ConfiguredMenuBarDynamicRunner.run and use
ConfiguredMenuBarDynamicCommandResult/ConfiguredMenuBarOutputCollector, add the
new package import where the controller references these symbols, and leave
ConfiguredMenuBarController (and all AppKit-dependent logic) in the existing app
module. Ensure you preserve behavior: keep the same APIs (function signatures,
constants like defaultTimeoutSeconds and outputLimitBytes) and tests can be
added against the new package.

Comment on lines +220 to +230
timer.schedule(deadline: .now() + max(0.1, timeoutSeconds))
timer.setEventHandler {
finishLock.lock()
guard !didFinish else {
finishLock.unlock()
return
}
didTimeOut = true
finishLock.unlock()
process.terminate()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

🧩 Analysis chain

🌐 Web query:

macOS Swift Process spawn new process group setpgid posix_spawn

💡 Result:

In POSIX posix_spawn, “set a new process group” is done via the spawn attributes object: set the spawn-flags bit POSIX_SPAWN_SETPGROUP and set spawn-pgroup (often to 0) with posix_spawnattr_setpgroup(). When the flag is set and spawn-pgroup is 0, the child is placed in a new process group with a PGID equal to its PID (equivalent to setpgid(pid=0, pgid=pid)-style behavior). [1] How this maps to the APIs you’d use from Swift on macOS 1) Initialize a posix_spawnattr_t with posix_spawnattr_init(). [2] 2) Enable process-group setting by calling posix_spawnattr_setflags(..., POSIX_SPAWN_SETPGROUP); that flag makes the child behave “as if the setpgid(2) function had been called with a pid parameter of 0 and a pgid parameter equal to the value of the spawn-pgroup attribute”. [3] 3) Set the spawn-pgroup attribute with posix_spawnattr_setpgroup(attr, pgroup). The pgroup argument is the process group the new process will join/create when POSIX_SPAWN_SETPGROUP is set; its default is 0 (which means a new process group will be created). [4] 4) Call posix_spawn() with attrp pointing at that configured attributes object. [5][1] Key semantics (authoritative) - If POSIX_SPAWN_SETPGROUP is set and spawn-pgroup is non-zero, the child’s process group is set to that specified value. [1] - Special case: if POSIX_SPAWN_SETPGROUP is set and spawn-pgroup is 0, the child is put into a new process group with PGID == child PID. [1] - If POSIX_SPAWN_SETPGROUP is not set, the child inherits the parent’s process group. [1] Swift notes - Swift itself doesn’t expose setpgid directly for Process/NSTask; the POSIX mechanism is through posix_spawnattr_setflags/posix_spawnattr_setpgroup + posix_spawn. (You can call these C functions from Swift by importing Darwin.) [3][4] - If you want the “new process group” behavior, set spawn-pgroup to 0 and set POSIX_SPAWN_SETPGROUP in the attributes flags; that yields PGID==PID for the spawned child. [1] Example (C semantics expressed as steps) - posix_spawnattr_init(&attr) - posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP) - posix_spawnattr_setpgroup(&attr, 0) // request a new pgroup; PGID==child PID [1] - posix_spawn(&pid, path, file_actions, &attr, argv, envp) [5][1] If you share your current Swift code (where you build the argv/envp and which spawn API you’re using), I can translate this into a concrete Swift/Darwin snippet that compiles.

Citations:


🏁 Script executed:

# First, let's look at the file and understand the context around lines 220-230
head -240 Sources/ConfiguredMenuBarController.swift | tail -50

Repository: manaflow-ai/cmux

Length of output: 1615


🏁 Script executed:

# Let's see the full function containing the timeout code to understand process setup
grep -n "func\|let process\|var process\|bash" Sources/ConfiguredMenuBarController.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 1730


🏁 Script executed:

# Let's examine the full runBlocking function starting from line 104
sed -n '104,235p' Sources/ConfiguredMenuBarController.swift

Repository: manaflow-ai/cmux

Length of output: 5233


🏁 Script executed:

# Now let's verify the technical claim about bash signal handling
# Check if there's any existing process group handling in the codebase
rg -i "setpgid\|POSIX_SPAWN_SETPGROUP\|process.*group" Sources/

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Let's check if there's any documentation or tests related to dynamic command execution
find . -name "*.swift" -type f | xargs grep -l "ConfiguredMenuBar\|dynamicCommand" | head -5

Repository: manaflow-ai/cmux

Length of output: 132


🏁 Script executed:

# Let's verify the actual behavior of bash with -lc regarding signal forwarding
# Search for any existing literature or tests in the repo about this
rg -i "SIGTERM\|terminate\|signal\|bash" Sources/ConfiguredMenuBarController.swift -A 2 -B 2

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Let's check what happens after process.terminate() is called
# and verify if there's any process cleanup code
rg "terminate\|Process\|kill" Sources/ConfiguredMenuBarController.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Let's verify the actual file and see if there's any file length concern
wc -l Sources/ConfiguredMenuBarController.swift

Repository: manaflow-ai/cmux

Length of output: 106


🏁 Script executed:

# Let's check for any existing process management or signal handling patterns in the codebase
rg -i "Darwin\|Foundation.*Process\|posix" Sources/ | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Let's check the cmux custom lint rules mentioned in coding guidelines
cat .github/review-bot-rules/*.swift 2>/dev/null | head -50

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check if the review-bot-rules directory exists and what's in it
ls -la .github/review-bot-rules/ 2>/dev/null || echo "Directory not found"

Repository: manaflow-ai/cmux

Length of output: 1012


🏁 Script executed:

# Check the blocking runtime rule which seems relevant to this process timeout scenario
cat .github/review-bot-rules/swift-blocking-runtime.md

Repository: manaflow-ai/cmux

Length of output: 2279


🏁 Script executed:

# Let's verify the actual behavior of bash with signal forwarding
# Create a simple test to understand the claim better
cat > /tmp/test_bash_signals.sh << 'EOF'
#!/bin/bash
# This script demonstrates the signal forwarding behavior
# When bash receives SIGTERM, it may not forward to all descendants

# Example: sleep in background
bash -lc "sleep 1000 &" &
PID=$!

# Get the process group
ps -o pid,ppid,pgid,cmd | grep $PID

# Kill the parent bash
kill -TERM $PID 2>/dev/null

# Check if sleep is still running
sleep 0.5
ps aux | grep "sleep 1000" | grep -v grep || echo "Sleep process was cleaned up"
EOF
chmod +x /tmp/test_bash_signals.sh
bash /tmp/test_bash_signals.sh 2>&1 || true

Repository: manaflow-ai/cmux

Length of output: 249


🏁 Script executed:

# Let's understand how this function is being called and what commands are executed
grep -n "runBlocking\|ConfiguredMenuBarDynamicCommandResult" Sources/ConfiguredMenuBarController.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 609


🏁 Script executed:

# Let's verify if there are any tests or examples of how commands are actually used
grep -n "dynamicCommand\|async\|run" Sources/ConfiguredMenuBarController.swift | grep -i "func\|test" | head -10

Repository: manaflow-ai/cmux

Length of output: 239


🏁 Script executed:

# Let's check if there are any integration tests or examples that show what kinds of commands are executed
find . -name "*.swift" -type f | xargs grep -l "ConfiguredMenuBar\|dynamic.*command" | xargs grep -h "command.*=" | head -10

Repository: manaflow-ai/cmux

Length of output: 850


Timeout only sends SIGTERM to bash, not its child processes—consider process-group signaling for cleanup.

When process.terminate() is called on a bash process launched with -lc, it sends SIGTERM only to the bash parent. Child processes spawned within that command (e.g., sleep 1000 | jq ...) may not receive the signal and can persist as orphans after the timeout, continuing to run invisibly even though the menu has finished.

To ensure all descendants are terminated on timeout, consider starting the process as a process-group leader using posix_spawn attributes (Darwin/POSIX APIs: set POSIX_SPAWN_SETPGROUP and call posix_spawnattr_setpgroup(attr, 0) to create a new group with PGID==child PID), then signal the entire process group on timeout/cancel. This requires importing Darwin and using low-level spawn attributes since Foundation's Process class does not expose process group configuration.

This is an optional improvement for robustness in menus that execute arbitrary user-supplied shell commands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfiguredMenuBarController.swift` around lines 220 - 230, The
timeout handler currently calls process.terminate() (see timer.setEventHandler,
finishLock, didFinish, didTimeOut) which only SIGTERM's the bash parent and can
leave child processes running; replace this by spawning the command as a new
process group using POSIX APIs (import Darwin, create posix_spawnattr_t with
POSIX_SPAWN_SETPGROUP and call posix_spawnattr_setpgroup(attr, 0) so the child
becomes a group leader) and store the child PID, then on timeout send the signal
to the whole group (use kill(-childPid, SIGTERM) and optionally SIGKILL after a
grace period) instead of calling process.terminate(); ensure cleanup paths
(cancel/normal finish) also signal the group and avoid races with
finishLock/didFinish/didTimeOut.

Comment on lines +691 to +696
private func cancelDynamicTask(sourceID: String) {
dynamicTasks[sourceID]?.cancel()
dynamicTasks.removeValue(forKey: sourceID)
dynamicStates[sourceID]?.activeRunID = nil
dynamicStates[sourceID]?.activePID = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Subprocess survives Task cancellation — orphaned bash runs until timeout.

cancelDynamicTask cancels the Swift Task and clears activeRunID/activePID, but the underlying Process started inside ConfiguredMenuBarDynamicRunner.run(...) is not signaled. Because run uses a plain withCheckedContinuation that resumes only on terminationHandler or the timeout timer, the bash process keeps running (and consuming CPU/output buffers) for up to timeoutSeconds after cancellation. This path is reachable from:

  • dynamicSourceMenuItem when a source's command changes (line 558),
  • resetDynamicSources() when configured menus are removed,
  • deinit (lines 314–316) — orphan bash invocations at app teardown,
  • and any future caller of cancelDynamicTask.

The runID guard at line 873 prevents stale results from corrupting state, but it does not stop the process. Recommend wiring cancellation through to process.terminate() (or kill(pid, SIGTERM)), e.g. via withTaskCancellationHandler in the runner, or by capturing the started PID in dynamicStates[...].activePID and signaling it before clearing in cancelDynamicTask.

🧹 Sketch: terminate the captured PID on cancellation
 private func cancelDynamicTask(sourceID: String) {
+    if let pid = dynamicStates[sourceID]?.activePID, pid > 0 {
+        kill(pid, SIGTERM)
+    }
     dynamicTasks[sourceID]?.cancel()
     dynamicTasks.removeValue(forKey: sourceID)
     dynamicStates[sourceID]?.activeRunID = nil
     dynamicStates[sourceID]?.activePID = nil
 }

A cleaner alternative is for ConfiguredMenuBarDynamicRunner.run to wrap its continuation in withTaskCancellationHandler and call process.terminate() from the cancellation handler, so callers don't need PID bookkeeping.

Also applies to: 829-883

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfiguredMenuBarController.swift` around lines 691 - 696, The
cancelDynamicTask currently cancels the Swift Task and clears
dynamicStates[sourceID].activeRunID/activePID but never signals the underlying
Process, leaving orphaned bash processes; update cancellation to terminate the
subprocess by sending a signal to the captured PID
(dynamicStates[sourceID].activePID) before clearing it (e.g., call kill(pid,
SIGTERM) or Process.terminate()), and/or modify
ConfiguredMenuBarDynamicRunner.run to wrap its withCheckedContinuation in
withTaskCancellationHandler so the cancellation handler calls
process.terminate() (or kills the PID) to ensure the subprocess is stopped when
dynamicTasks[sourceID]?.cancel() is invoked.

Comment thread web/data/cmux.schema.json
Comment on lines +996 to +1012
"refresh": {
"type": "string",
"enum": ["onOpen", "manual", "onConfigReload", "interval"],
"default": "onOpen",
"description": "When cmux runs this source."
},
"timeoutSeconds": {
"type": "number",
"exclusiveMinimum": 0,
"default": 5,
"description": "Maximum runtime before cmux terminates the command."
},
"intervalSeconds": {
"type": "number",
"minimum": 10,
"description": "Required when refresh is interval. cmux only runs interval sources automatically after the source has already been trusted or when it comes from global config."
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

intervalSeconds is documented as required when refresh is interval but the schema never enforces it.

The description on line 1011 states intervalSeconds is required when refresh is interval, but the schema lacks a conditional constraint, so { "command": "...", "refresh": "interval" } validates. Add an allOf/if/then (similar to the pattern used in sessionIdSource above) so users get a schema error instead of relying on runtime behavior.

Suggested fix
     "menuBarSource": {
       "type": "object",
       "additionalProperties": false,
       "required": ["command"],
       "properties": {
         ...
         "intervalSeconds": {
           "type": "number",
           "minimum": 10,
           "description": "Required when refresh is interval. cmux only runs interval sources automatically after the source has already been trusted or when it comes from global config."
         }
-      }
+      },
+      "allOf": [
+        {
+          "if": {
+            "properties": { "refresh": { "const": "interval" } },
+            "required": ["refresh"]
+          },
+          "then": { "required": ["intervalSeconds"] }
+        }
+      ]
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"refresh": {
"type": "string",
"enum": ["onOpen", "manual", "onConfigReload", "interval"],
"default": "onOpen",
"description": "When cmux runs this source."
},
"timeoutSeconds": {
"type": "number",
"exclusiveMinimum": 0,
"default": 5,
"description": "Maximum runtime before cmux terminates the command."
},
"intervalSeconds": {
"type": "number",
"minimum": 10,
"description": "Required when refresh is interval. cmux only runs interval sources automatically after the source has already been trusted or when it comes from global config."
}
"refresh": {
"type": "string",
"enum": ["onOpen", "manual", "onConfigReload", "interval"],
"default": "onOpen",
"description": "When cmux runs this source."
},
"timeoutSeconds": {
"type": "number",
"exclusiveMinimum": 0,
"default": 5,
"description": "Maximum runtime before cmux terminates the command."
},
"intervalSeconds": {
"type": "number",
"minimum": 10,
"description": "Required when refresh is interval. cmux only runs interval sources automatically after the source has already been trusted or when it comes from global config."
}
},
"allOf": [
{
"if": {
"properties": { "refresh": { "const": "interval" } },
"required": ["refresh"]
},
"then": { "required": ["intervalSeconds"] }
}
]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/data/cmux.schema.json` around lines 996 - 1012, Add a JSON Schema
conditional so intervalSeconds is required when refresh equals "interval":
modify the schema block containing the "refresh" and "intervalSeconds"
properties to include an if/then (or allOf with if/then) that checks
{"properties":{"refresh":{"const":"interval"}}} and in the then clause adds
"required":["intervalSeconds"] and optionally constraints on intervalSeconds;
follow the same pattern used for sessionIdSource to locate where to insert the
conditional so the validator rejects objects missing intervalSeconds when
refresh is "interval".

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Comment thread Sources/AppDelegate.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ConfiguredMenuBarController.swift`:
- Around line 366-372: The current call that appends the item count uses a
simple "%d items" format and the localization key
"taskManager.dynamicMenu.items", which will produce incorrect singular forms;
update the usage to employ pluralized localization entries (ICU-style .one and
.other) for "taskManager.dynamicMenu.items" and format using the plural-aware
API so that detailParts.append(...) uses the pluralized localization based on
generatedItemCount (reference the existing call site with detailParts.append and
the generatedItemCount variable) ensuring the .one and .other keys are present
in the strings file and the runtime selects the correct plural form.
- Around line 133-145: The timer created by DispatchSource.makeTimerSource
(timer) must be armed/resumed before any path can call finish(), because
finish() may cancel the timer and calling cancel() on a suspended timer is
undefined; resume (or otherwise schedule/activate) the timer right after
creation and before calling process.run() so that finish() can safely call
timer.cancel(); ensure you only resume/arm the timer once (avoid double-resume)
and reference the existing finish(), finishLock, didFinish, continuation and the
process.run() call when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45a3ed65-f606-4201-abdd-3af0b7b4893d

📥 Commits

Reviewing files that changed from the base of the PR and between 21798fb and 20a4c77.

📒 Files selected for processing (1)
  • Sources/ConfiguredMenuBarController.swift

Comment on lines +133 to +145
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))

func finish(_ result: ConfiguredMenuBarDynamicCommandResult) {
finishLock.lock()
guard !didFinish else {
finishLock.unlock()
return
}
didFinish = true
finishLock.unlock()
timer.cancel()
continuation.resume(returning: result)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

wc -l Sources/ConfiguredMenuBarController.swift

Repository: manaflow-ai/cmux

Length of output: 107


🏁 Script executed:

sed -n '120,250p' Sources/ConfiguredMenuBarController.swift

Repository: manaflow-ai/cmux

Length of output: 4981


Don't cancel the timer before it has been resumed.

If process.run() throws at line 203, finish() cancels the timer at line 143 before line 220 ever resumes it. Calling cancel() on a suspended DispatchSourceTimer produces undefined behavior, which can crash on systems with certain dispatch implementations. Arm the timer before attempting process.run() to ensure it's always in a safe state before cancellation.

🩹 Minimal fix
+        timer.schedule(deadline: .now() + max(0.1, timeoutSeconds))
+        timer.setEventHandler {
+            finishLock.lock()
+            guard !didFinish else {
+                finishLock.unlock()
+                return
+            }
+            didTimeOut = true
+            finishLock.unlock()
+            process.terminate()
+        }
+        timer.resume()
+
         do {
             try process.run()
             onStarted(process.processIdentifier)
         } catch {
             let durationMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
             stdoutPipe.fileHandleForReading.readabilityHandler = nil
             stderrPipe.fileHandleForReading.readabilityHandler = nil
             finish(ConfiguredMenuBarDynamicCommandResult(
                 stdout: "",
                 stderr: "",
                 exitStatus: nil,
                 durationMS: durationMS,
                 errorMessage: error.localizedDescription
             ))
             return
         }
-
-        timer.schedule(deadline: .now() + max(0.1, timeoutSeconds))
-        timer.setEventHandler {
-            finishLock.lock()
-            guard !didFinish else {
-                finishLock.unlock()
-                return
-            }
-            didTimeOut = true
-            finishLock.unlock()
-            process.terminate()
-        }
-        timer.resume()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfiguredMenuBarController.swift` around lines 133 - 145, The timer
created by DispatchSource.makeTimerSource (timer) must be armed/resumed before
any path can call finish(), because finish() may cancel the timer and calling
cancel() on a suspended timer is undefined; resume (or otherwise
schedule/activate) the timer right after creation and before calling
process.run() so that finish() can safely call timer.cancel(); ensure you only
resume/arm the timer once (avoid double-resume) and reference the existing
finish(), finishLock, didFinish, continuation and the process.run() call when
making the change.

Comment on lines +366 to +372
detailParts.append(String(
format: String(
localized: "taskManager.dynamicMenu.items",
defaultValue: "%d items"
),
generatedItemCount
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use pluralized localization keys for the item count.

"%d items" will render "1 items" for the singular case and won’t localize correctly across locales. Please switch this to .one / .other keys for taskManager.dynamicMenu.items. Based on learnings: "In Swift files (cmux project), when handling pluralized strings, prefer using localization keys with the ICU-style plural forms .one and .other."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ConfiguredMenuBarController.swift` around lines 366 - 372, The
current call that appends the item count uses a simple "%d items" format and the
localization key "taskManager.dynamicMenu.items", which will produce incorrect
singular forms; update the usage to employ pluralized localization entries
(ICU-style .one and .other) for "taskManager.dynamicMenu.items" and format using
the plural-aware API so that detailParts.append(...) uses the pluralized
localization based on generatedItemCount (reference the existing call site with
detailParts.append and the generatedItemCount variable) ensuring the .one and
.other keys are present in the strings file and the runtime selects the correct
plural form.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Comment thread Sources/ConfiguredMenuBarController.swift
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Comment on lines +938 to +946
if let issue = resolved.issues.first {
throw NSError(domain: "CmuxDynamicMenu", code: 1, userInfo: [
NSLocalizedDescriptionKey: issue.logMessage
])
}
state.phase = .loaded
state.items = resolved.items
state.error = nil
state.generatedItemCount = resolved.items.count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Valid resolved items are silently dropped when any item has a validation error. resolveGeneratedMenuBarItems already skips invalid items via continue, so resolved.items contains only the items that resolved successfully. Throwing on the first issue discards those valid items too: a dynamic command that returns 9 valid action refs plus 1 bad ref fails completely instead of rendering the 9 good items. The state transition to .failed happens in the catch block anyway, so the error notification and "Dynamic menu failed" UI are unaffected by updating state.items first.

Suggested change
if let issue = resolved.issues.first {
throw NSError(domain: "CmuxDynamicMenu", code: 1, userInfo: [
NSLocalizedDescriptionKey: issue.logMessage
])
}
state.phase = .loaded
state.items = resolved.items
state.error = nil
state.generatedItemCount = resolved.items.count
state.phase = resolved.issues.isEmpty ? .loaded : .failed
state.items = resolved.items
state.error = resolved.issues.first.map { $0.logMessage }
state.generatedItemCount = resolved.items.count
if let issue = resolved.issues.first {
owner?.notifyConfiguredMenuBarDynamicFailure(
source: source,
error: issue.logMessage,
preferredWindow: preferredWindow
)
}

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f45866d. Configure here.

Comment thread Sources/CmuxConfig.swift Outdated
Comment on lines +844 to +849

private func dynamicMenuWillOpen(sourceID: String, preferredWindow: NSWindow?) {
guard let source = dynamicSourceByID[sourceID] else { return }
renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow)
if (source.source.refresh ?? .onOpen) == .onOpen {
refreshDynamicSource(sourceID: sourceID, preferredWindow: preferredWindow, reason: .open)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Subprocess orphaned on Task cancellation

cancelDynamicTask calls task.cancel() but never terminates the underlying Process. Because ConfiguredMenuBarDynamicRunner.run uses withCheckedContinuation with no withTaskCancellationHandler, Swift Task cancellation has no effect on the GCD block running runBlocking — the subprocess continues until it exits or the timeout fires (up to timeoutSeconds, user-configurable). When startDynamicSource cancels and re-spawns (e.g., rapid user-triggered reloads or interval misfires during config reload), every prior subprocess runs to completion invisibly: the PID is cleared from dynamicStates by cancelDynamicTask, so it disappears from Task Manager even though the process is still alive. For interval sources with custom timeoutSeconds values, multiple orphaned processes can accumulate.

The fix is to pass the Process reference out of run via the existing onStarted callback pattern and call process.terminate() inside a withTaskCancellationHandler around the withCheckedContinuation call, or store the Process on the task side and cancel it when the Task is cancelled.

Comment thread Sources/CmuxConfig.swift
Comment on lines 2383 to 2393
@@ -2313,6 +2393,36 @@ final class CmuxConfigStore: ObservableObject {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate shortcut registrations from action-ref menu items

menuBarShortcutActions() appends every menu bar action with a non-nil shortcut. When a menu bar item uses an action reference (the item.action path in resolvedMenuBarAction), the resolved CmuxResolvedConfigAction comes directly from loadedActions. If that action already satisfies the configuredActions filter — shortcut != nil && (builtInIDs.contains(id) || actionSourcePath != nil) — it appears in both configuredActions and menuBarShortcutActions, causing shortcutActions() to return it twice. Depending on how callers register keyboard shortcuts, this can result in the same shortcut firing the same action twice per keypress, or conflicting handler registrations.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 9ace32bd Deployed May 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants