Socket CLI: prevent focus stealing + add rename-tab and focus regressions
This commit is contained in:
parent
fd5726c653
commit
4cbdd999d8
10 changed files with 665 additions and 151 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
76
docs/socket-focus-steal-audit.todo.md
Normal file
76
docs/socket-focus-steal-audit.todo.md
Normal 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).
|
||||
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal file
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal 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())
|
||||
129
tests_v2/test_rename_tab_cli_parity.py
Normal file
129
tests_v2/test_rename_tab_cli_parity.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue