diff --git a/Sources/AppDelegate+NewWorkspaceMenuRendering.swift b/Sources/AppDelegate+NewWorkspaceMenuRendering.swift index d7b9f4c0f453..ce5547f8a9ea 100644 --- a/Sources/AppDelegate+NewWorkspaceMenuRendering.swift +++ b/Sources/AppDelegate+NewWorkspaceMenuRendering.swift @@ -216,6 +216,19 @@ extension AppDelegate { parent.submenu = submenu items.append(parent) } + items.append(.separator()) + let actionsItem = NSMenuItem( + title: Self.actionsAndLaunchersMenuTitle, + action: #selector(presentActionsAndLaunchersMenuItem(_:)), + keyEquivalent: "" + ) + actionsItem.target = self + actionsItem.representedObject = context.windowId as NSUUID + actionsItem.image = NSImage( + systemSymbolName: "slider.horizontal.3", + accessibilityDescription: nil + ) + items.append(actionsItem) addRenderedSection(items) } } diff --git a/Sources/AppDelegate+WorkspaceActionSave.swift b/Sources/AppDelegate+WorkspaceActionSave.swift index 459aec59a9d1..ea36ff9f4ed4 100644 --- a/Sources/AppDelegate+WorkspaceActionSave.swift +++ b/Sources/AppDelegate+WorkspaceActionSave.swift @@ -28,7 +28,246 @@ final class WorkspaceDefaultLayoutBox: NSObject { } } +/// Read-only projection of the live cmux.json action registry for native +/// discovery UI. It deliberately carries only resolved action metadata and +/// effective placements; execution stays on the existing action paths. +struct ActionsAndLaunchersDiscoveryModel: Equatable { + struct Entry: Equatable { + let id: String + let title: String + let actionType: String + let sourcePath: String + let appearsInCommandPalette: Bool + let isNewWorkspaceDefault: Bool + let appearsInNewWorkspaceMenu: Bool + let appearsInSurfaceTabBar: Bool + let shortcutDisplay: String? + + var detailTokens: [String] { + var tokens = ["type=\(actionType)"] + if appearsInCommandPalette { + tokens.append("palette") + } + if isNewWorkspaceDefault { + tokens.append("ui.newWorkspace.action") + } + if appearsInNewWorkspaceMenu { + tokens.append("ui.newWorkspace.contextMenu") + } + if appearsInSurfaceTabBar { + tokens.append("ui.surfaceTabBar.buttons") + } + if let shortcutDisplay { + tokens.append("shortcut=\(shortcutDisplay)") + } + return tokens + } + } + + let entries: [Entry] + + static func build( + actions: [CmuxResolvedConfigAction], + resolvedNewWorkspaceActionID: String?, + newWorkspaceMenuActionIDs: Set, + surfaceTabBarActionIDs: Set + ) -> ActionsAndLaunchersDiscoveryModel { + let entries = actions.compactMap { action -> Entry? in + // Built-ins that only come from cmux itself add noise here. An + // overridden built-in has a source path and remains discoverable. + guard let sourcePath = action.actionSourcePath else { return nil } + let shortcutDisplay = action.shortcut.flatMap { shortcut in + shortcut.isUnbound ? nil : shortcut.displayString + } + return Entry( + id: action.id, + title: action.title, + actionType: actionType(action.action), + sourcePath: sourcePath, + appearsInCommandPalette: action.palette, + isNewWorkspaceDefault: action.id == resolvedNewWorkspaceActionID, + appearsInNewWorkspaceMenu: newWorkspaceMenuActionIDs.contains(action.id), + appearsInSurfaceTabBar: surfaceTabBarActionIDs.contains(action.id), + shortcutDisplay: shortcutDisplay + ) + } + .sorted { + let titleOrder = $0.title.localizedStandardCompare($1.title) + if titleOrder != .orderedSame { + return titleOrder == .orderedAscending + } + return $0.id.localizedStandardCompare($1.id) == .orderedAscending + } + return ActionsAndLaunchersDiscoveryModel(entries: entries) + } + + var summaryText: String { + guard !entries.isEmpty else { return "actions: {}" } + return entries.map { entry in + let source = (entry.sourcePath as NSString).abbreviatingWithTildeInPath + return [ + "\(entry.title) [\(entry.id)]", + " " + entry.detailTokens.joined(separator: " · "), + " " + source, + ].joined(separator: "\n") + } + .joined(separator: "\n\n") + } + + private static func actionType(_ action: CmuxSurfaceTabBarButtonAction) -> String { + switch action { + case .builtIn: + return "builtin" + case .command: + return "command" + case .agent: + return "agent" + case .workspaceCommand: + return "workspaceCommand" + case .workspace: + return "workspace" + case .actionReference: + return "action" + } + } +} + extension AppDelegate { + static var actionsAndLaunchersMenuTitle: String { + String( + localized: "debug.titlebarLayoutDebug.actions", + defaultValue: "Actions" + ) + " · cmux.json…" + } + + private static var actionsAndLaunchersDialogTitle: String { + String( + localized: "debug.titlebarLayoutDebug.actions", + defaultValue: "Actions" + ) + " · cmux.json" + } + + func presentActionsAndLaunchersCustomization(preferredWindow: NSWindow? = nil) { + let context = [ + preferredWindow, + NSApp.keyWindow, + NSApp.mainWindow, + shortcutRoutingActiveWindow, + ] + .compactMap { $0 } + .compactMap { contextForMainWindow($0) } + .first + + let cmuxConfigStore: CmuxConfigStore + if let activeStore = context?.cmuxConfigStore { + cmuxConfigStore = activeStore + } else { + let globalStore = CmuxConfigStore() + globalStore.loadAll() + cmuxConfigStore = globalStore + } + + let newWorkspaceMenuActionIDs = Set( + cmuxConfigStore.newWorkspaceContextMenuItems.compactMap { item -> String? in + guard case .action(let menuAction) = item else { return nil } + return menuAction.action.id + } + ) + let model = ActionsAndLaunchersDiscoveryModel.build( + actions: cmuxConfigStore.loadedActions, + resolvedNewWorkspaceActionID: cmuxConfigStore.resolvedNewWorkspaceAction()?.id, + newWorkspaceMenuActionIDs: newWorkspaceMenuActionIDs, + surfaceTabBarActionIDs: Set(cmuxConfigStore.surfaceTabBarActionReferenceIDs.values) + ) + + let alert = NSAlert() + alert.messageText = Self.actionsAndLaunchersDialogTitle + alert.informativeText = (cmuxConfigStore.globalConfigPath as NSString).abbreviatingWithTildeInPath + alert.accessoryView = actionsAndLaunchersAccessoryView( + text: model.summaryText, + entryCount: model.entries.count + ) + let openLabel = String( + localized: "settings.settingsJSON.openButton", + defaultValue: "Open" + ) + let globalPath = (cmuxConfigStore.globalConfigPath as NSString).abbreviatingWithTildeInPath + alert.addButton(withTitle: "\(openLabel) \(globalPath)") + alert.addButton(withTitle: String( + localized: "settings.settingsJSON.docsButton", + defaultValue: "Open Docs" + )) + alert.addButton(withTitle: String( + localized: "common.ok", + defaultValue: "OK" + )) + + let presentingWindow = context.flatMap { resolvedWindow(for: $0) } ?? preferredWindow + if let presentingWindow { + alert.beginSheetModal(for: presentingWindow) { [weak self] response in + self?.handleActionsAndLaunchersResponse(response) + } + } else { + handleActionsAndLaunchersResponse(alert.runModal()) + } + } + + @objc func presentActionsAndLaunchersMenuItem(_ sender: NSMenuItem) { + let preferredWindow: NSWindow? + if let windowId = (sender.representedObject as? NSUUID) as UUID?, + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + preferredWindow = resolvedWindow(for: context) + } else { + preferredWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + presentActionsAndLaunchersCustomization(preferredWindow: preferredWindow) + } + + private func actionsAndLaunchersAccessoryView( + text: String, + entryCount: Int + ) -> NSView { + let height = min(CGFloat(360), max(CGFloat(120), CGFloat(max(entryCount, 1)) * 64)) + let size = NSSize(width: 620, height: height) + let textView = NSTextView(frame: NSRect(origin: .zero, size: size)) + textView.string = text + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.drawsBackground = false + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.maxSize = NSSize(width: size.width, height: .greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: size.width, + height: .greatestFiniteMagnitude + ) + + let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: size)) + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .bezelBorder + scrollView.documentView = textView + return scrollView + } + + private func handleActionsAndLaunchersResponse(_ response: NSApplication.ModalResponse) { + switch response { + case .alertFirstButtonReturn: + // Reuse the existing in-cmux config editor path used by Settings' + // workspace-layout customization. + openWorkspaceLayoutsCustomization() + case .alertSecondButtonReturn: + guard let url = URL(string: "https://cmux.com/docs/custom-commands") else { return } + NSWorkspace.shared.open(url) + default: + break + } + } /// Actions defined in the global config (where saved workspace layouts /// write) are deletable from the UI; project-local and built-in actions diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index dee799b2b0fe..7accce5f6159 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1707,6 +1707,10 @@ final class CmuxConfigStore: ObservableObject { /// configured. @Published private(set) var workspaceGroupConfigs: [CmuxResolvedWorkspaceGroupConfig] = [] @Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults + /// Original action-registry reference keyed by resolved surface-tab button id. + /// Inline buttons have no entry; this survives resolution so discovery UI + /// never has to infer action identity from equal payloads. + @Published private(set) var surfaceTabBarActionReferenceIDs: [String: String] = [:] @Published private(set) var notificationHooks: [CmuxResolvedNotificationHook] = [] @Published private(set) var configurationIssues: [CmuxConfigIssue] = [] @Published private(set) var configRevision: UInt64 = 0 @@ -1736,11 +1740,13 @@ final class CmuxConfigStore: ObservableObject { private struct ResolvedSurfaceTabBarButtonEntry { let button: CmuxSurfaceTabBarButton let terminalCommandSourcePath: String? + let actionReferenceID: String? } private struct ResolvedSurfaceTabBarButtons { let buttons: [CmuxSurfaceTabBarButton] let terminalCommandSourcePaths: [String: String] + let actionReferenceIDs: [String: String] } private struct ResolvedContextMenuItems { @@ -2075,7 +2081,8 @@ final class CmuxConfigStore: ObservableObject { settingName: "ui.surfaceTabBar.buttons" ) ?? ResolvedSurfaceTabBarButtons( buttons: defaultResolvedButtons, - terminalCommandSourcePaths: [:] + terminalCommandSourcePaths: [:], + actionReferenceIDs: [:] ) let resolvedWorkspaceButtons = resolvedSurfaceTabBarWorkspaceCommands( resolvedButtons.buttons, @@ -2136,6 +2143,10 @@ final class CmuxConfigStore: ObservableObject { workspaceGroupConfigs = resolvedGroupConfigs surfaceTabBarButtonSourcePath = configuredSurfaceTabBarButtonSourcePath surfaceTabBarCommandSourcePaths = resolvedButtons.terminalCommandSourcePaths + let visibleSurfaceButtonIDs = Set(resolvedWorkspaceButtons.buttons.map(\.id)) + surfaceTabBarActionReferenceIDs = resolvedButtons.actionReferenceIDs.filter { + visibleSurfaceButtonIDs.contains($0.key) + } surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands surfaceTabBarButtons = resolvedWorkspaceButtons.buttons notificationHooks = resolvedNotificationHooks @@ -2330,12 +2341,16 @@ final class CmuxConfigStore: ObservableObject { ) -> ResolvedSurfaceTabBarButtons? { var resolvedButtons: [CmuxSurfaceTabBarButton] = [] var terminalCommandSourcePaths: [String: String] = [:] + var actionReferenceIDs: [String: String] = [:] resolvedButtons.reserveCapacity(buttons.count) for button in buttons { do { let resolved = try resolvedSurfaceTabBarButton(button, actions: actions) resolvedButtons.append(resolved.button) + if let actionReferenceID = resolved.actionReferenceID { + actionReferenceIDs[resolved.button.id] = actionReferenceID + } guard resolved.button.terminalCommand != nil else { continue } if let commandSourcePath = resolved.terminalCommandSourcePath { terminalCommandSourcePaths[resolved.button.id] = commandSourcePath @@ -2348,7 +2363,8 @@ final class CmuxConfigStore: ObservableObject { return ResolvedSurfaceTabBarButtons( buttons: resolvedButtons, - terminalCommandSourcePaths: terminalCommandSourcePaths + terminalCommandSourcePaths: terminalCommandSourcePaths, + actionReferenceIDs: actionReferenceIDs ) } @@ -2357,7 +2373,11 @@ final class CmuxConfigStore: ObservableObject { actions: [String: CmuxResolvedConfigAction] ) throws -> ResolvedSurfaceTabBarButtonEntry { guard case .actionReference(let identifier) = button.action else { - return ResolvedSurfaceTabBarButtonEntry(button: button, terminalCommandSourcePath: nil) + return ResolvedSurfaceTabBarButtonEntry( + button: button, + terminalCommandSourcePath: nil, + actionReferenceID: nil + ) } let resolvedIdentifier = canonicalActionID(identifier) @@ -2375,7 +2395,8 @@ final class CmuxConfigStore: ObservableObject { ) return ResolvedSurfaceTabBarButtonEntry( button: resolvedButton, - terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath + terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath, + actionReferenceID: resolvedIdentifier ) } @@ -2390,7 +2411,8 @@ final class CmuxConfigStore: ObservableObject { confirm: button.confirm, terminalCommandTarget: button.terminalCommandTarget ), - terminalCommandSourcePath: nil + terminalCommandSourcePath: nil, + actionReferenceID: builtIn.configID ) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 27083ded829d..3b8ad91dc74f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -501,6 +501,11 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } + Button(AppDelegate.actionsAndLaunchersMenuTitle) { + appDelegate.presentActionsAndLaunchersCustomization( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) + } Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux.json")) { openCmuxSettingsFileInEditor() } diff --git a/cmuxTests/CmuxConfigNewWorkspaceMenuTests.swift b/cmuxTests/CmuxConfigNewWorkspaceMenuTests.swift index 95e7e6137287..4abbff9d0301 100644 --- a/cmuxTests/CmuxConfigNewWorkspaceMenuTests.swift +++ b/cmuxTests/CmuxConfigNewWorkspaceMenuTests.swift @@ -342,10 +342,13 @@ struct CmuxConfigNewWorkspaceMenuTests { try withNewWorkspaceContextMenu(store: store) { menu in let nonSeparators = menu.items.filter { !$0.isSeparatorItem } - let save = try #require(nonSeparators.dropLast().last) - let manage = try #require(nonSeparators.last) + let save = try #require(nonSeparators.dropLast(2).last) + let manage = try #require(nonSeparators.dropLast().last) + let actions = try #require(nonSeparators.last) #expect(save.title == String(localized: "menu.newWorkspace.saveWorkspaceAsLayout", defaultValue: "Save Workspace as Layout…")) #expect(manage.title == String(localized: "menu.newWorkspace.manageLayouts", defaultValue: "Manage Layouts")) + #expect(actions.title == AppDelegate.actionsAndLaunchersMenuTitle) + #expect(actions.action == Selector(("presentActionsAndLaunchersMenuItem:"))) let submenu = try #require(manage.submenu) #expect(submenu.items.contains { $0.title == String(localized: "menu.newWorkspace.defaultLayoutSubmenu", defaultValue: "Default for New Workspace") }) let none = try #require(submenu.items.first { diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 56f92a3fd118..22ca5351d702 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -377,6 +377,7 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(store.surfaceTabBarButtonSourcePath, localConfigURL.path) XCTAssertEqual(store.surfaceTabBarButtons.first?.terminalCommand, "codex --yolo") XCTAssertEqual(store.surfaceTabBarCommandSourcePaths["start-codex"], globalConfigURL.path) + XCTAssertEqual(store.surfaceTabBarActionReferenceIDs["start-codex"], "start-codex") } func testDecodeActionIconObjectsSupportAllFormats() throws { @@ -1299,13 +1300,17 @@ final class CmuxConfigDecodingTests: XCTestCase { let configURL = root.appendingPathComponent("cmux.json") let json = """ { + "actions": { + "hidden-ref": { "type": "workspaceCommand", "commandName": "Missing Environment" } + }, "ui": { "surfaceTabBar": { "buttons": [ { "action": "newTerminal" }, { "id": "dev", "type": "workspaceCommand", "commandName": "Dev Environment" }, { "id": "typo", "type": "workspaceCommand", "commandName": "Typo" }, - { "id": "simple", "type": "workspaceCommand", "commandName": "Run Tests" } + { "id": "simple", "type": "workspaceCommand", "commandName": "Run Tests" }, + { "id": "hidden-ref-button", "action": "hidden-ref" } ] } }, @@ -1332,6 +1337,7 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(store.surfaceTabBarButtons.map(\.id), ["newTerminal", "dev"]) XCTAssertEqual(store.surfaceTabBarButtons.last?.workspaceCommandName, "Dev Environment") + XCTAssertNil(store.surfaceTabBarActionReferenceIDs["hidden-ref-button"]) } func testDecodeEmptySurfaceTabBarButtons() throws { diff --git a/cmuxTests/CmuxConfigWorkspaceActionTests.swift b/cmuxTests/CmuxConfigWorkspaceActionTests.swift index 7a72210f4062..936e3641bf35 100644 --- a/cmuxTests/CmuxConfigWorkspaceActionTests.swift +++ b/cmuxTests/CmuxConfigWorkspaceActionTests.swift @@ -222,6 +222,87 @@ struct CmuxConfigWorkspaceActionTests { #expect(optedIn.wantsNewWorkspaceMenu) } + @Test func actionsAndLaunchersDiscoveryReportsEffectivePlacements() throws { + let configPath = "/Users/test/.config/cmux/cmux.json" + let shortcut = try #require(StoredShortcut.parseConfig("cmd+shift+r")) + let agent = try #require(CmuxResolvedConfigAction.fromDefinition( + id: "review-agent", + definition: CmuxConfigActionDefinition( + action: .agent(.claudeCode, args: nil), + title: "Review Agent", + palette: true, + shortcut: shortcut + ), + sourcePath: configPath + )) + let layout = try #require(CmuxResolvedConfigAction.fromDefinition( + id: "review-layout", + definition: CmuxConfigActionDefinition( + action: .workspace(CmuxWorkspaceDefinition(name: "Review"), restart: nil), + title: "Review Layout", + palette: false, + newWorkspaceMenu: true + ), + sourcePath: configPath + )) + let model = ActionsAndLaunchersDiscoveryModel.build( + actions: [CmuxResolvedConfigAction.builtIn(.newTerminal), layout, agent], + resolvedNewWorkspaceActionID: layout.id, + newWorkspaceMenuActionIDs: [layout.id], + surfaceTabBarActionIDs: [agent.id] + ) + + #expect(model.entries.map(\.id) == [agent.id, layout.id]) + + let agentEntry = try #require(model.entries.first { $0.id == agent.id }) + #expect(agentEntry.actionType == "agent") + #expect(agentEntry.appearsInCommandPalette) + #expect(!agentEntry.isNewWorkspaceDefault) + #expect(!agentEntry.appearsInNewWorkspaceMenu) + #expect(agentEntry.appearsInSurfaceTabBar) + #expect(agentEntry.shortcutDisplay == shortcut.displayString) + + let layoutEntry = try #require(model.entries.first { $0.id == layout.id }) + #expect(layoutEntry.actionType == "workspace") + #expect(!layoutEntry.appearsInCommandPalette) + #expect(layoutEntry.isNewWorkspaceDefault) + #expect(layoutEntry.appearsInNewWorkspaceMenu) + #expect(!layoutEntry.appearsInSurfaceTabBar) + #expect(layoutEntry.shortcutDisplay == nil) + + #expect(!model.entries.contains { $0.id == CmuxSurfaceTabBarBuiltInAction.newTerminal.configID }) + } + + @Test func actionsDiscoveryDoesNotInferSurfacePlacementFromEqualPayloads() throws { + let configPath = "/Users/test/.config/cmux/cmux.json" + let first = try #require(CmuxResolvedConfigAction.fromDefinition( + id: "first", + definition: CmuxConfigActionDefinition( + action: .command("echo same"), + title: "First" + ), + sourcePath: configPath + )) + let second = try #require(CmuxResolvedConfigAction.fromDefinition( + id: "second", + definition: CmuxConfigActionDefinition( + action: .command("echo same"), + title: "Second" + ), + sourcePath: configPath + )) + + let model = ActionsAndLaunchersDiscoveryModel.build( + actions: [first, second], + resolvedNewWorkspaceActionID: nil, + newWorkspaceMenuActionIDs: [], + surfaceTabBarActionIDs: [first.id] + ) + + #expect(model.entries.first { $0.id == first.id }?.appearsInSurfaceTabBar == true) + #expect(model.entries.first { $0.id == second.id }?.appearsInSurfaceTabBar == false) + } + // MARK: - Executor @Test func inlineWorkspaceSyntheticCommandCarriesConfirm() throws { diff --git a/cmuxTests/NewWorkspaceMenuModelTests.swift b/cmuxTests/NewWorkspaceMenuModelTests.swift index 428acf13824a..5409f4b6355b 100644 --- a/cmuxTests/NewWorkspaceMenuModelTests.swift +++ b/cmuxTests/NewWorkspaceMenuModelTests.swift @@ -63,7 +63,10 @@ struct NewWorkspaceMenuModelTests { cmuxConfigStore: store )) let titles = menu.items.filter { !$0.isSeparatorItem }.map(\.title) - #expect(titles == [String(localized: "menu.newWorkspace.saveWorkspaceAsLayout", defaultValue: "Save Workspace as Layout…")]) + #expect(titles == [ + String(localized: "menu.newWorkspace.saveWorkspaceAsLayout", defaultValue: "Save Workspace as Layout…"), + AppDelegate.actionsAndLaunchersMenuTitle, + ]) } @MainActor