Socket CLI: prevent focus stealing + add rename-tab and focus regressions

This commit is contained in:
Lawrence Chen 2026-02-21 02:21:27 -08:00
parent fd5726c653
commit 4cbdd999d8
10 changed files with 665 additions and 151 deletions

View file

@ -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`:

View file

@ -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 <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
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).
"""

View file

@ -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)
if TerminalController.shouldSuppressSocketCommandActivation() {
window.orderFront(nil)
if TerminalController.socketCommandAllowsInAppFocusMutations() {
setActiveMainWindow(window)
}
} else {
window.makeKeyAndOrderFront(nil)
setActiveMainWindow(window)
NSApp.activate(ignoringOtherApps: true)
}
return windowId
}

View file

@ -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)
}
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
}

View file

@ -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,6 +515,7 @@ class TerminalController {
let cmd = parts[0].lowercased()
let args = parts.count > 1 ? parts[1] : ""
return withSocketCommandPolicy(commandKey: cmd, isV2: false) {
switch cmd {
case "ping":
return "PONG"
@ -714,6 +807,7 @@ class TerminalController {
return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands."
}
}
}
// MARK: - V2 JSON Socket Protocol
@ -747,6 +841,7 @@ class TerminalController {
v2MainSync { self.v2RefreshKnownRefs() }
return withSocketCommandPolicy(commandKey: method, isV2: true) {
switch method {
case "system.ping":
return v2Ok(id: id, result: ["pong": true])
@ -1086,6 +1181,7 @@ class TerminalController {
return v2Error(id: id, code: "method_not_found", message: "Unknown method")
}
}
}
private func v2Capabilities() -> [String: Any] {
var methods: [String] = [
@ -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)
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 {

View file

@ -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,11 +928,15 @@ 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.
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,11 +1049,15 @@ final class Workspace: Identifiable, ObservableObject {
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
}
installBrowserPanelSubscription(browserPanel)

View file

@ -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).

View file

@ -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())

View file

@ -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())

View file

@ -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)