diff --git a/CLAUDE.md b/CLAUDE.md index 4d57c596..bc3d5bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,12 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Commands that directly manipulate AppKit/Ghostty UI state (focus/select/open/close/send key/input, list/current queries requiring exact synchronous snapshot) are allowed to run on main actor. - If adding a new socket command, default to off-main handling; require an explicit reason in code comments when main-thread execution is necessary. +## Socket focus policy + +- Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects). +- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents). +- All non-focus commands should preserve current user focus context while still applying data/model changes. + ## E2E mac UI tests Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 88cab6e8..32e62915 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -589,6 +589,9 @@ struct CMUXCLI { case "tab-action": try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "rename-tab": + try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "list-workspaces": let payload = try client.sendV2(method: "workspace.list") if jsonOutput { @@ -1727,6 +1730,55 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } + private func runRenameTab( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") + let (tabOpt, rem1) = parseOption(rem0, name: "--tab") + let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") + let (titleOpt, rem3) = parseOption(rem2, name: "--title") + + if rem3.contains("--action") { + throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") + } + if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") + } + + let inferredTitle = rem3 + .dropFirst(rem3.first == "--" ? 1 : 0) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let title, !title.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + + var forwarded: [String] = ["--action", "rename", "--title", title] + if let workspaceOpt { + forwarded += ["--workspace", workspaceOpt] + } + if let tabOpt { + forwarded += ["--tab", tabOpt] + } else if let surfaceOpt { + forwarded += ["--surface", surfaceOpt] + } + + try runTabAction( + commandArgs: forwarded, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + } + private func runBrowserCommand( commandArgs: [String], client: SocketClient, @@ -3046,6 +3098,26 @@ struct CMUXCLI { cmux tab-action --action close-right cmux tab-action --tab tab:2 --action rename --title "build logs" """ + case "rename-tab": + return """ + Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] + + Rename a tab (surface). Defaults to the focused tab, using: + 1) explicit --tab/--surface + 2) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 3) focused tab in the resolved workspace context + + Flags: + --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) + --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) + --surface <id|ref> Alias for --tab + --title <text> New title (or pass trailing title) + + Example: + cmux rename-tab "build logs" + cmux rename-tab --tab tab:3 "staging server" + cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" + """ case "new-workspace": return """ Usage: cmux new-workspace @@ -4320,6 +4392,7 @@ struct CMUXCLI { move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] + rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces surface-health [--workspace <id|ref>] @@ -4410,7 +4483,7 @@ struct CMUXCLI { Environment: CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for ALL commands (send, list-panels, new-split, notify, etc.). - CMUX_TAB_ID Optional alias used by `tab-action` as default --tab. + CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). """ diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9c0ad241..debc0e09 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -562,6 +562,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func focusMainWindow(windowId: UUID) -> Bool { guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation() { + if window.isMiniaturized { + window.deminiaturize(nil) + } + if TerminalController.socketCommandAllowsInAppFocusMutations() { + window.orderFront(nil) + setActiveMainWindow(window) + } + return true + } bringToFront(window) return true } @@ -736,9 +746,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState: sidebarSelectionState ) installFileDropOverlay(on: window, tabManager: tabManager) - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) + if TerminalController.shouldSuppressSocketCommandActivation() { + window.orderFront(nil) + if TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + } + } else { + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + NSApp.activate(ignoringOtherApps: true) + } return windowId } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 016765cd..f8efd5ce 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -502,7 +502,7 @@ class TabManager: ObservableObject { } @discardableResult - func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil) -> Workspace { + func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -514,17 +514,19 @@ class TabManager: ObservableObject { } else { tabs.append(newWorkspace) } - selectedTabId = newWorkspace.id - NotificationCenter.default.post( - name: .ghosttyDidFocusTab, - object: nil, - userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] - ) + if select { + selectedTabId = newWorkspace.id + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] + ) + } #if DEBUG UITestRecorder.incrementInt("addTabInvocations") UITestRecorder.record([ "tabCount": String(tabs.count), - "selectedTabId": newWorkspace.id.uuidString + "selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") ]) #endif return newWorkspace @@ -532,7 +534,7 @@ class TabManager: ObservableObject { // Keep addTab as convenience alias @discardableResult - func addTab() -> Workspace { addWorkspace() } + func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } @@ -1503,12 +1505,13 @@ class TabManager: ObservableObject { /// Create a new split in the specified direction /// Returns the new panel's ID (which is also the surface ID for terminals) - func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? { + func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, - insertFirst: direction.insertFirst + insertFirst: direction.insertFirst, + focus: focus )?.id } @@ -1559,14 +1562,16 @@ class TabManager: ObservableObject { fromPanelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil + url: URL? = nil, + focus: Bool = true ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newBrowserSplit( from: fromPanelId, orientation: orientation, insertFirst: insertFirst, - url: url + url: url, + focus: focus )?.id } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3217b154..3fa0d18f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -18,6 +18,36 @@ class TerminalController { private var tabManager: TabManager? private var accessMode: SocketControlMode = .cmuxOnly private let myPid = getpid() + private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 + private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = [] + private nonisolated static let socketCommandPolicyLock = NSLock() + + private static let focusIntentV1Commands: Set<String> = [ + "focus_window", + "select_workspace", + "focus_surface", + "focus_pane", + "focus_surface_by_panel", + "focus_webview", + "focus_notification", + "activate_app" + ] + + private static let focusIntentV2Methods: Set<String> = [ + "window.focus", + "workspace.select", + "workspace.next", + "workspace.previous", + "workspace.last", + "surface.focus", + "pane.focus", + "pane.last", + "browser.focus_webview", + "browser.focus", + "browser.tab.switch", + "debug.notification.focus", + "debug.app.activate" + ] private enum V2HandleKind: String, CaseIterable { case window @@ -68,6 +98,68 @@ class TerminalController { private init() {} + nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandPolicyDepth > 0 + } + + nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool { + allowsInAppFocusMutationsForActiveSocketCommand() + } + + private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandFocusAllowanceStack.last ?? false + } + + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { + if isV2 { + return focusIntentV2Methods.contains(commandKey) + } + return focusIntentV1Commands.contains(commandKey) + } + + private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T { + let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2) + Self.socketCommandPolicyLock.lock() + Self.socketCommandPolicyDepth += 1 + Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation) + Self.socketCommandPolicyLock.unlock() + defer { + Self.socketCommandPolicyLock.lock() + if !Self.socketCommandFocusAllowanceStack.isEmpty { + _ = Self.socketCommandFocusAllowanceStack.popLast() + } + Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1) + Self.socketCommandPolicyLock.unlock() + } + return body() + } + + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, key: String, @@ -423,7 +515,8 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - switch cmd { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + switch cmd { case "ping": return "PONG" @@ -713,6 +806,7 @@ class TerminalController { default: return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." } + } } // MARK: - V2 JSON Socket Protocol @@ -747,7 +841,8 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - switch method { + return withSocketCommandPolicy(commandKey: method, isV2: true) { + switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": @@ -1085,6 +1180,7 @@ class TerminalController { default: return v2Error(id: id, code: "method_not_found", message: "Unknown method") } + } } private func v2Capabilities() -> [String: Any] { @@ -1624,8 +1720,9 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // The new window should become key, but setActiveTabManager defensively. - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // Keep active routing stable unless this command is explicitly focus-intent. + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -1685,7 +1782,7 @@ class TerminalController { var newId: UUID? v2MainSync { - let ws = tabManager.addWorkspace() + let ws = tabManager.addWorkspace(select: v2FocusAllowed()) newId = ws.id } @@ -1711,12 +1808,8 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - // If this workspace belongs to another window, bring it forward so focus is visible. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - tabManager.selectWorkspace(ws) + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) success = true } } @@ -1789,7 +1882,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -1909,10 +2002,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -1934,10 +2024,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -1959,10 +2046,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -2151,6 +2235,7 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } + let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -2280,7 +2365,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: true + focus: allowFocusMutation ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -2301,7 +2386,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -2328,7 +2413,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -2439,7 +2524,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -2515,15 +2600,8 @@ class TerminalController { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - // Make sure the workspace is selected so focus effects apply to the visible UI. - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -2551,13 +2629,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -2569,7 +2642,12 @@ class TerminalController { return } - if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { + if let newId = tabManager.newSplit( + tabId: ws.id, + surfaceId: targetSurfaceId, + direction: direction, + focus: v2FocusAllowed() + ) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -2604,13 +2682,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { @@ -2627,9 +2700,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: true)?.id + newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: true)?.id + newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id } guard let newPanelId else { @@ -2747,7 +2820,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -2858,16 +2931,15 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - _ = app.focusMainWindow(windowId: targetWindowId) - setActiveTabManager(targetTabManager) - targetTabManager.selectWorkspace(targetWorkspace) + v2MaybeFocusWindow(for: targetTabManager) + v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) } result = .ok([ @@ -3257,14 +3329,9 @@ class TerminalController { return } - // Ensure the flash is visible in the active UI. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + // Only explicit focus-intent commands may mutate selection state. + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -3345,13 +3412,8 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -3432,13 +3494,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard let focusedPanelId = ws.focusedPanelId else { result = .err(code: "not_found", message: "No focused surface to split", data: nil) return @@ -3446,9 +3503,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = ws.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: v2FocusAllowed() + )?.id } else { - newPanelId = ws.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = ws.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: v2FocusAllowed() + )?.id } guard let newPanelId else { @@ -3665,7 +3733,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -3748,7 +3816,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -3789,7 +3857,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace() + let destinationWorkspace = tabManager.addWorkspace(select: focus) guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -3797,7 +3865,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -3810,16 +3878,12 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } - - if !focus { - tabManager.selectWorkspace(sourceWorkspace) - } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -4356,13 +4420,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let sourceSurfaceId else { @@ -4380,11 +4439,16 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) + createdPanel = ws.newBrowserSplit( + from: sourceSurfaceId, + orientation: .horizontal, + url: url, + focus: v2FocusAllowed() + ) } guard let browserPanelId = createdPanel?.id else { @@ -5639,13 +5703,8 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -6755,7 +6814,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -6800,7 +6859,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -8635,7 +8694,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -8655,6 +8715,7 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false + let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -8662,9 +8723,11 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: true) - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) + dstTM.attachWorkspace(ws, select: focus) + if focus { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) + } ok = true } @@ -8689,9 +8752,10 @@ class TerminalController { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var newTabId: UUID? + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { - tabManager.addTab() - newTabId = tabManager.selectedTabId + let workspace = tabManager.addTab(select: focus) + newTabId = workspace.id } return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -8736,7 +8800,12 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { + if let newPanelId = tabManager.newSplit( + tabId: tabId, + surfaceId: targetSurface, + direction: direction, + focus: socketCommandAllowsInAppFocusMutations() + ) { result = "OK \(newPanelId.uuidString)" } } @@ -9896,6 +9965,7 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) + let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" DispatchQueue.main.sync { @@ -9905,7 +9975,12 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { + if let browserPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: .horizontal, + url: url, + focus: shouldFocus + )?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -10307,6 +10382,7 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst + let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -10318,9 +10394,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: shouldFocus + )?.id } else { - newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = tab.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: shouldFocus + )?.id } if let id = newPanelId { @@ -11235,6 +11322,7 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil + let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -11279,9 +11367,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id } if let id = newPanelId { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6e8a71e9..1238c5fb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -629,6 +629,12 @@ final class Workspace: Identifiable, ObservableObject { return surfaceKind(for: panel) } + func panelTitle(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + let fallback = panelTitles[panelId] ?? panel.displayTitle + return resolvedPanelTitle(panelId: panelId, fallback: fallback) + } + func setPanelPinned(panelId: UUID, pinned: Bool) { guard panels[panelId] != nil else { return } let wasPinned = pinnedPanelIds.contains(panelId) @@ -848,7 +854,8 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSplit( from panelId: UUID, orientation: SplitOrientation, - insertFirst: Bool = false + insertFirst: Bool = false, + focus: Bool = true ) -> TerminalPanel? { // Get inherited config from the source terminal when possible. // If the split is initiated from a non-terminal panel (for example browser), @@ -921,10 +928,14 @@ final class Workspace: Identifiable, ObservableObject { // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, // stealing focus from the new panel and creating model/surface divergence. - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() } return newPanel @@ -993,7 +1004,8 @@ final class Workspace: Identifiable, ObservableObject { from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil + url: URL? = nil, + focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } @@ -1037,10 +1049,14 @@ final class Workspace: Identifiable, ObservableObject { // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() } installBrowserPanelSubscription(browserPanel) diff --git a/docs/socket-focus-steal-audit.todo.md b/docs/socket-focus-steal-audit.todo.md new file mode 100644 index 00000000..3042050a --- /dev/null +++ b/docs/socket-focus-steal-audit.todo.md @@ -0,0 +1,76 @@ +# Socket/CLI No-Focus-Steal Todo + +## Goal +Ensure commands run through the cmux Unix socket/CLI do not steal user focus from the current UI workflow. + +Policy target: +- App activation/window raising from socket commands: **never**. +- In-app focus mutation from socket commands: only for explicit focus-intent commands. +- Non-focus commands must not move workspace/pane/surface focus as a side effect. + +## Task Checklist +- [x] Inventory all v1 + v2 socket command entrypoints. +- [x] Add socket-command focus policy context in `TerminalController`. +- [x] Suppress app activation for socket command path in `AppDelegate` (`focusMainWindow`, `createMainWindow`). +- [x] Gate in-app focus mutation side-effects in v2 handlers. +- [x] Gate in-app focus mutation side-effects in legacy v1 handlers. +- [x] Add explicit CLI `rename-tab` command with env-default targeting. +- [x] Update CLI help/usage/subcommand docs for `rename-tab`. +- [x] Add regression tests for rename-tab and no-unintended-focus-side-effects. +- [x] Run build + targeted tests. +- [ ] Open PR. + +## Explicit Focus-Intent Allowlist +These may mutate in-app focus/selection state: + +v1: +- `focus_window` +- `select_workspace` +- `focus_surface` +- `focus_pane` +- `focus_surface_by_panel` +- `focus_webview` +- `focus_notification` (debug) +- `activate_app` (debug) + +v2: +- `window.focus` +- `workspace.select` +- `workspace.next` +- `workspace.previous` +- `workspace.last` +- `surface.focus` +- `pane.focus` +- `pane.last` +- `browser.focus_webview` +- `browser.focus` +- `browser.tab.switch` +- `debug.notification.focus` +- `debug.app.activate` + +All other commands should preserve current user focus context. + +## Command Coverage Matrix (All Command Families) +- [x] v1 `ping`, `help` +- [x] v1 window commands (`list_windows`, `current_window`, `focus_window`, `new_window`, `close_window`) +- [x] v1 workspace commands (`move_workspace_to_window`, `list_workspaces`, `new_workspace`, `close_workspace`, `select_workspace`, `current_workspace`) +- [x] v1 surface/pane commands (`new_split`, `list_surfaces`, `focus_surface`, `list_panes`, `list_pane_surfaces`, `focus_pane`, `focus_surface_by_panel`, `drag_surface_to_split`, `new_pane`, `new_surface`, `close_surface`, `refresh_surfaces`, `surface_health`) +- [x] v1 input commands (`send`, `send_key`, `send_surface`, `send_key_surface`, `read_screen`) +- [x] v1 notification/status/log/report commands (`notify*`, `list_notifications`, `clear_notifications`, `set_status`, `clear_status`, `list_status`, `log`, `clear_log`, `list_log`, `set_progress`, `clear_progress`, `report_*`, `ports_kick`, `sidebar_state`, `reset_sidebar`) +- [x] v1 browser commands (`open_browser`, `navigate`, `browser_back`, `browser_forward`, `browser_reload`, `get_url`, `focus_webview`, `is_webview_focused`) +- [x] v1 debug/test commands (shortcut, type, drop/pasteboard, overlay probes, focus checks, screenshots, render/layout/flash/panel snapshot) + +- [x] v2 system methods (`system.*`) +- [x] v2 window methods (`window.*`) +- [x] v2 workspace methods (`workspace.*`) +- [x] v2 surface methods (`surface.*`, `tab.action`) +- [x] v2 pane methods (`pane.*`) +- [x] v2 notification methods (`notification.*`) +- [x] v2 app methods (`app.*`) +- [x] v2 browser methods (full `browser.*` set including tab/network/trace/input) +- [x] v2 debug methods (`debug.*`) + +## CLI Coverage +- [x] Ensure every top-level CLI command routes to non-focus-stealing socket behavior. +- [x] Add/verify `rename-workspace` + `rename-window` behavior remains intact. +- [x] Add explicit `rename-tab` command (defaults to `CMUX_TAB_ID` / `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` when flags omitted). diff --git a/tests_v2/test_cli_non_focus_commands_preserve_workspace.py b/tests_v2/test_cli_non_focus_commands_preserve_workspace.py new file mode 100644 index 00000000..dbc28f9b --- /dev/null +++ b/tests_v2/test_cli_non_focus_commands_preserve_workspace.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Regression: non-focus CLI commands should not switch the selected workspace.""" + +import glob +import os +import subprocess +import sys +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str]) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout.strip() + + +def _current_workspace(c: cmux) -> str: + payload = c._call("workspace.current") or {} + ws_id = str(payload.get("workspace_id") or "") + if not ws_id: + raise cmuxError(f"workspace.current returned no workspace_id: {payload}") + return ws_id + + +def main() -> int: + cli = _find_cli_binary() + + with cmux(SOCKET_PATH) as c: + baseline_ws = _current_workspace(c) + + created = _run_cli(cli, ["new-workspace"]) + _must(created.startswith("OK "), f"new-workspace expected OK response, got: {created}") + created_ws = created.removeprefix("OK ").strip() + _must(bool(created_ws), f"new-workspace returned no workspace id: {created}") + _must(_current_workspace(c) == baseline_ws, "new-workspace should not switch selected workspace") + + _run_cli(cli, ["new-surface", "--workspace", created_ws]) + _must(_current_workspace(c) == baseline_ws, "new-surface --workspace should not switch selected workspace") + + _run_cli(cli, ["new-pane", "--workspace", created_ws, "--direction", "right"]) + _must(_current_workspace(c) == baseline_ws, "new-pane --workspace should not switch selected workspace") + + _run_cli(cli, ["tab-action", "--workspace", created_ws, "--action", "new-terminal-right"]) + _must(_current_workspace(c) == baseline_ws, "tab-action new-terminal-right should not switch selected workspace") + + c.close_workspace(created_ws) + + print("PASS: non-focus CLI commands preserve selected workspace") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py new file mode 100644 index 00000000..a60055fa --- /dev/null +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Regression: explicit `rename-tab` CLI command parity with tab.action rename.""" + +import glob +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> str: + merged_env = dict(os.environ) + merged_env.pop("CMUX_WORKSPACE_ID", None) + merged_env.pop("CMUX_SURFACE_ID", None) + merged_env.pop("CMUX_TAB_ID", None) + if env: + merged_env.update(env) + + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged_env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout.strip() + + +def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: + payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} + for row in payload.get("surfaces") or []: + if str(row.get("id") or "") == surface_id: + return str(row.get("title") or "") + raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("tab.action" in methods, f"Missing tab.action in capabilities: {sorted(methods)[:40]}") + + created = c._call("workspace.create") or {} + ws_id = str(created.get("workspace_id") or "") + _must(bool(ws_id), f"workspace.create returned no workspace_id: {created}") + + c._call("workspace.select", {"workspace_id": ws_id}) + current = c._call("surface.current", {"workspace_id": ws_id}) or {} + surface_id = str(current.get("surface_id") or "") + _must(bool(surface_id), f"surface.current returned no surface_id: {current}") + + socket_title = f"socket rename {stamp}" + c._call( + "tab.action", + { + "workspace_id": ws_id, + "surface_id": surface_id, + "action": "rename", + "title": socket_title, + }, + ) + _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + + cli_title = f"cli rename {stamp}" + _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + + env_title = f"env rename {stamp}" + _run_cli( + cli, + ["rename-tab", env_title], + env={ + "CMUX_WORKSPACE_ID": ws_id, + "CMUX_TAB_ID": surface_id, + }, + ) + _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], + capture_output=True, + text=True, + check=False, + env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_TAB_ID"}}, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected rename-tab without title to fail") + _must("rename-tab requires a title" in invalid_output, f"Unexpected rename-tab error: {invalid_output!r}") + + c.close_workspace(ws_id) + + print("PASS: rename-tab CLI parity works with explicit and env-derived targets") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py index 13e564c1..6c33cdb5 100644 --- a/tests_v2/test_rename_window_workspace_parity.py +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -7,7 +7,7 @@ import subprocess import sys import time from pathlib import Path -from typing import List +from typing import Dict, List, Optional sys.path.insert(0, str(Path(__file__).parent)) from cmux import cmux, cmuxError @@ -39,11 +39,13 @@ def _find_cli_binary() -> str: return candidates[0] -def _run_cli(cli: str, args: List[str]) -> str: +def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str: env = dict(os.environ) # Keep this test deterministic when running from inside another cmux shell. env.pop("CMUX_WORKSPACE_ID", None) env.pop("CMUX_SURFACE_ID", None) + if env_overrides: + env.update(env_overrides) cmd = [cli, "--socket", SOCKET_PATH] + args proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) if proc.returncode != 0: @@ -93,6 +95,17 @@ def main() -> int: "cmux rename-window without --workspace should target current workspace", ) + env_title = f"tmux env {stamp}" + _run_cli( + cli, + ["rename-workspace", env_title], + env_overrides={"CMUX_WORKSPACE_ID": ws_id}, + ) + _must( + _workspace_title(c, ws_id) == env_title, + "cmux rename-workspace should default to CMUX_WORKSPACE_ID", + ) + env = dict(os.environ) env.pop("CMUX_WORKSPACE_ID", None) env.pop("CMUX_SURFACE_ID", None)