From 10e44396dfd598aab0370dbc1ec655b7b4b6d917 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:20:55 -0800 Subject: [PATCH] Add tab/workspace action APIs and consistent naming --- CLI/cmux.swift | 196 +++++++++++++++ Sources/ContentView.swift | 39 +-- Sources/TerminalController.swift | 417 +++++++++++++++++++++++++++++++ 3 files changed, 633 insertions(+), 19 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b0740d9f..63536e6d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -583,6 +583,12 @@ struct CMUXCLI { case "reorder-workspace": try runReorderWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "workspace-action": + try runWorkspaceAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + + case "tab-action": + try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "list-workspaces": let payload = try client.sendV2(method: "workspace.list") if jsonOutput { @@ -1492,6 +1498,144 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } + private func runWorkspaceAction( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") + let (actionOpt, rem1) = parseOption(rem0, name: "--action") + let (titleOpt, rem2) = parseOption(rem1, name: "--title") + + var positional = rem2 + let actionRaw: String + if let actionOpt { + actionRaw = actionOpt + } else if let first = positional.first { + actionRaw = first + positional.removeFirst() + } else { + throw CLIError(message: "workspace-action requires --action ") + } + + if let unknown = positional.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "workspace-action: unknown flag '\(unknown)'") + } + + let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_") + let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + + let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines) + + if action == "rename", (title?.isEmpty ?? true) { + throw CLIError(message: "workspace-action rename requires --title (or a trailing title)") + } + + var params: [String: Any] = ["action": action] + if let workspaceId { + params["workspace_id"] = workspaceId + } + if let title, !title.isEmpty { + params["title"] = title + } + + let payload = try client.sendV2(method: "workspace.action", params: params) + var summaryParts = ["OK", "action=\(action)"] + if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { + summaryParts.append("workspace=\(workspaceHandle)") + } + if let windowHandle = formatHandle(payload, kind: "window", idFormat: idFormat) { + summaryParts.append("window=\(windowHandle)") + } + if let closed = payload["closed"] { + summaryParts.append("closed=\(closed)") + } + if let index = payload["index"] { + summaryParts.append("index=\(index)") + } + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) + } + + private func runTabAction( + 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 (actionOpt, rem3) = parseOption(rem2, name: "--action") + let (titleOpt, rem4) = parseOption(rem3, name: "--title") + let (urlOpt, rem5) = parseOption(rem4, name: "--url") + + var positional = rem5 + let actionRaw: String + if let actionOpt { + actionRaw = actionOpt + } else if let first = positional.first { + actionRaw = first + positional.removeFirst() + } else { + throw CLIError(message: "tab-action requires --action ") + } + + if let unknown = positional.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "tab-action: unknown flag '\(unknown)'") + } + + let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_") + let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let tabArg = tabOpt ?? surfaceOpt ?? (workspaceOpt == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + + let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + let surfaceId = try normalizeSurfaceHandle(tabArg, client: client, workspaceHandle: workspaceId, allowFocused: true) + + let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines) + + if action == "rename", (title?.isEmpty ?? true) { + throw CLIError(message: "tab-action rename requires --title (or a trailing title)") + } + + var params: [String: Any] = ["action": action] + if let workspaceId { + params["workspace_id"] = workspaceId + } + if let surfaceId { + params["surface_id"] = surfaceId + } + if let title, !title.isEmpty { + params["title"] = title + } + if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + let payload = try client.sendV2(method: "tab.action", params: params) + var summaryParts = ["OK", "action=\(action)"] + if let tabHandle = formatHandle(payload, kind: "surface", idFormat: idFormat) { + summaryParts.append("tab=\(tabHandle)") + } + if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { + summaryParts.append("workspace=\(workspaceHandle)") + } + if let closed = payload["closed"] { + summaryParts.append("closed=\(closed)") + } + if let created = payload["created_surface_ref"] as? String { + summaryParts.append("created=\(created)") + } else if let createdId = payload["created_surface_id"] as? String { + summaryParts.append("created=\(createdId)") + } + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) + } + private func runBrowserCommand( commandArgs: [String], client: SocketClient, @@ -2761,6 +2905,56 @@ struct CMUXCLI { cmux reorder-workspace --workspace workspace:2 --index 0 cmux reorder-workspace --workspace workspace:3 --after workspace:1 """ + case "workspace-action": + return """ + Usage: cmux workspace-action --action [flags] + + Perform workspace context-menu actions from CLI/socket. + + Actions: + pin | unpin + rename | clear-name + move-up | move-down | move-top + close-others | close-above | close-below + mark-read | mark-unread + + Flags: + --action Action name (required if not positional) + --workspace Target workspace (default: current/$CMUX_WORKSPACE_ID) + --title Title for rename + + Example: + cmux workspace-action --workspace workspace:2 --action pin + cmux workspace-action --action rename --title "infra" + cmux workspace-action close-others + """ + case "tab-action": + return """ + Usage: cmux tab-action --action [flags] + + Perform horizontal tab context-menu actions from CLI/socket. + + Actions: + rename | clear-name + close-left | close-right | close-others + new-terminal-right | new-browser-right + reload | duplicate + pin | unpin + mark-unread + + Flags: + --action Action name (required if not positional) + --tab Target tab (alias: --surface) + --surface Alias for --tab + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --title Title for rename + --url Optional URL for new-browser-right + + Example: + cmux tab-action --tab surface:3 --action pin + cmux tab-action --action close-right + cmux tab-action --tab surface:2 --action rename --title "build logs" + """ case "new-workspace": return """ Usage: cmux new-workspace @@ -4021,6 +4215,7 @@ struct CMUXCLI { close-window --window move-workspace-to-window --workspace --window reorder-workspace --workspace (--index | --before | --after ) [--window ] + workspace-action --action [--workspace ] [--title ] list-workspaces new-workspace [--command ] new-split [--workspace ] [--surface ] [--panel ] @@ -4032,6 +4227,7 @@ struct CMUXCLI { close-surface [--surface ] [--workspace ] move-surface --surface [--pane ] [--workspace ] [--window ] [--before ] [--after ] [--index ] [--focus ] reorder-surface --surface (--index | --before | --after ) + tab-action --action [--tab ] [--workspace ] [--title ] [--url ] drag-surface-to-split --surface refresh-surfaces surface-health [--workspace ] diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6757b020..4f93b4d6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2421,8 +2421,11 @@ private struct TabItemView: View { let targetIds = contextTargetIds() let shouldPin = !tab.isPinned let pinLabel = targetIds.count > 1 - ? (shouldPin ? "Pin Tabs" : "Unpin Tabs") - : (shouldPin ? "Pin Tab" : "Unpin Tab") + ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") + : (shouldPin ? "Pin Workspace" : "Unpin Workspace") + let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace" + let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read" + let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread" Button(pinLabel) { for id in targetIds { if let tab = tabManager.tabs.first(where: { $0.id == id }) { @@ -2432,12 +2435,12 @@ private struct TabItemView: View { syncSelectionAfterMutation() } - Button("Rename Tab…") { + Button("Rename Workspace…") { promptRename() } if tab.hasCustomTitle { - Button("Remove Custom Name") { + Button("Remove Custom Workspace Name") { tabManager.clearCustomTitle(tabId: tab.id) } } @@ -2454,14 +2457,20 @@ private struct TabItemView: View { } .disabled(index >= tabManager.tabs.count - 1) + Button("Move to Top") { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + Divider() - Button("Close Workspaces") { + Button(closeLabel) { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) - Button("Close Others") { + Button("Close Other Workspaces") { closeOtherTabs(targetIds) } .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) @@ -2478,20 +2487,12 @@ private struct TabItemView: View { Divider() - Button("Move to Top") { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() - } - .disabled(targetIds.isEmpty) - - Divider() - - Button("Mark as Read") { + Button(markReadLabel) { markTabsRead(targetIds) } .disabled(!hasUnreadNotifications(in: targetIds)) - Button("Mark as Unread") { + Button(markUnreadLabel) { markTabsUnread(targetIds) } .disabled(!hasReadNotifications(in: targetIds)) @@ -2729,10 +2730,10 @@ private struct TabItemView: View { private func promptRename() { let alert = NSAlert() - alert.messageText = "Rename Tab" - alert.informativeText = "Enter a custom name for this tab." + alert.messageText = "Rename Workspace" + alert.informativeText = "Enter a custom name for this workspace." let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = "Tab name" + input.placeholderString = "Workspace name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input alert.addButton(withTitle: "Rename") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0e30968a..07291db3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -693,6 +693,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceReorder(params: params)) case "workspace.rename": return v2Result(id: id, self.v2WorkspaceRename(params: params)) + case "workspace.action": + return v2Result(id: id, self.v2WorkspaceAction(params: params)) case "workspace.next": return v2Result(id: id, self.v2WorkspaceNext(params: params)) case "workspace.previous": @@ -718,6 +720,10 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceMove(params: params)) case "surface.reorder": return v2Result(id: id, self.v2SurfaceReorder(params: params)) + case "surface.action": + return v2Result(id: id, self.v2TabAction(params: params)) + case "tab.action": + return v2Result(id: id, self.v2TabAction(params: params)) case "surface.drag_to_split": return v2Result(id: id, self.v2SurfaceDragToSplit(params: params)) case "surface.refresh": @@ -1007,6 +1013,7 @@ class TerminalController { "workspace.move_to_window", "workspace.reorder", "workspace.rename", + "workspace.action", "workspace.next", "workspace.previous", "workspace.last", @@ -1019,6 +1026,8 @@ class TerminalController { "surface.drag_to_split", "surface.move", "surface.reorder", + "surface.action", + "tab.action", "surface.refresh", "surface.health", "surface.send_text", @@ -1359,6 +1368,11 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { + guard let action = v2String(params, key) else { return nil } + return action.lowercased().replacingOccurrences(of: "-", with: "_") + } + private func v2RawString(_ params: [String: Any], _ key: String) -> String? { params[key] as? String } @@ -1851,6 +1865,409 @@ class TerminalController { return result } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let action = v2ActionKey(params) else { + return .err(code: "invalid_params", message: "Missing action", data: nil) + } + + let supportedActions = [ + "pin", "unpin", "rename", "clear_name", + "move_up", "move_down", "move_top", + "close_others", "close_above", "close_below", + "mark_read", "mark_unread" + ] + + var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [ + "action": action, + "supported_actions": supportedActions + ]) + + v2MainSync { + let requestedWorkspaceId = v2UUID(params, "workspace_id") ?? tabManager.selectedTabId + guard let workspaceId = requestedWorkspaceId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + + @MainActor + func closeWorkspaces(_ workspaces: [Workspace]) -> Int { + var closed = 0 + for candidate in workspaces where candidate.id != workspace.id { + let existedBefore = tabManager.tabs.contains(where: { $0.id == candidate.id }) + guard existedBefore else { continue } + tabManager.closeWorkspace(candidate) + if !tabManager.tabs.contains(where: { $0.id == candidate.id }) { + closed += 1 + } + } + return closed + } + + @MainActor + func finish(_ extras: [String: Any] = [:]) { + var payload: [String: Any] = [ + "action": action, + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ] + for (key, value) in extras { + payload[key] = value + } + result = .ok(payload) + } + + switch action { + case "pin": + tabManager.setPinned(workspace, pinned: true) + finish(["pinned": true]) + + case "unpin": + tabManager.setPinned(workspace, pinned: false) + finish(["pinned": false]) + + case "rename": + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + return + } + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + tabManager.setCustomTitle(tabId: workspace.id, title: title) + finish(["title": title]) + + case "clear_name": + tabManager.clearCustomTitle(tabId: workspace.id) + finish(["title": workspace.title]) + + case "move_up": + guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0)) + finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) + + case "move_down": + guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1)) + finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) + + case "move_top": + tabManager.moveTabToTop(workspace.id) + finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) + + case "close_others": + let candidates = tabManager.tabs.filter { $0.id != workspace.id && !$0.isPinned } + let closed = closeWorkspaces(candidates) + finish(["closed": closed]) + + case "close_above": + guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let candidates = Array(tabManager.tabs.prefix(index)).filter { !$0.isPinned } + let closed = closeWorkspaces(candidates) + finish(["closed": closed]) + + case "close_below": + guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let candidates: [Workspace] + if index + 1 < tabManager.tabs.count { + candidates = Array(tabManager.tabs.suffix(from: index + 1)).filter { !$0.isPinned } + } else { + candidates = [] + } + let closed = closeWorkspaces(candidates) + finish(["closed": closed]) + + case "mark_read": + AppDelegate.shared?.notificationStore?.markRead(forTabId: workspace.id) + finish() + + case "mark_unread": + AppDelegate.shared?.notificationStore?.markUnread(forTabId: workspace.id) + finish() + + default: + result = .err(code: "invalid_params", message: "Unknown workspace action", data: [ + "action": action, + "supported_actions": supportedActions + ]) + } + } + + return result + } + + private func v2TabAction(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let action = v2ActionKey(params) else { + return .err(code: "invalid_params", message: "Missing action", data: nil) + } + + let supportedActions = [ + "rename", "clear_name", + "close_left", "close_right", "close_others", + "new_terminal_right", "new_browser_right", + "reload", "duplicate", + "pin", "unpin", "mark_unread" + ] + + var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ + "action": action, + "supported_actions": supportedActions + ]) + + v2MainSync { + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let surfaceId = v2UUID(params, "surface_id") ?? workspace.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused tab", data: nil) + return + } + guard workspace.panels[surfaceId] != nil else { + result = .err(code: "not_found", message: "Tab not found", data: [ + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) + ]) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + + @MainActor + func finish(_ extras: [String: Any] = [:]) { + var payload: [String: Any] = [ + "action": action, + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) + ] + if let paneId = workspace.paneId(forPanelId: surfaceId)?.id { + payload["pane_id"] = paneId.uuidString + payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneId) + } else { + payload["pane_id"] = NSNull() + payload["pane_ref"] = NSNull() + } + for (key, value) in extras { + payload[key] = value + } + result = .ok(payload) + } + + @MainActor + func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int { + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } + let pinnedCount = tabs.reduce(into: 0) { count, tab in + if let panelId = workspace.panelIdFromSurfaceId(tab.id), + workspace.isPanelPinned(panelId) { + count += 1 + } + } + let rawTarget = min(anchorIndex + 1, tabs.count) + return max(rawTarget, pinnedCount) + } + + @MainActor + func closeTabs(_ tabIds: [TabID]) -> (closed: Int, skippedPinned: Int) { + var closed = 0 + var skippedPinned = 0 + for tabId in tabIds { + guard let panelId = workspace.panelIdFromSurfaceId(tabId) else { continue } + if workspace.isPanelPinned(panelId) { + skippedPinned += 1 + continue + } + if workspace.panels.count <= 1 { + break + } + if workspace.closePanel(panelId, force: true) { + closed += 1 + } + } + return (closed, skippedPinned) + } + + switch action { + case "rename": + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + return + } + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + workspace.setPanelCustomTitle(panelId: surfaceId, title: title) + finish(["title": title]) + + case "clear_name": + workspace.setPanelCustomTitle(panelId: surfaceId, title: nil) + finish() + + case "pin": + workspace.setPanelPinned(panelId: surfaceId, pinned: true) + finish(["pinned": true]) + + case "unpin": + workspace.setPanelPinned(panelId: surfaceId, pinned: false) + finish(["pinned": false]) + + case "mark_unread", "mark_as_unread": + workspace.markPanelUnread(surfaceId) + finish() + + case "reload", "reload_tab": + guard let browserPanel = workspace.browserPanel(for: surfaceId) else { + result = .err(code: "invalid_state", message: "Reload is only available for browser tabs", data: nil) + return + } + browserPanel.reload() + finish() + + case "duplicate", "duplicate_tab": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId), + let browserPanel = workspace.browserPanel(for: surfaceId) else { + result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil) + return + } + + let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newBrowserSurface( + inPane: paneId, + url: browserPanel.currentURL, + focus: true + ) else { + result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) + return + } + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + finish([ + "created_surface_id": newPanel.id.uuidString, + "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id) + ]) + + case "new_terminal_right", "new_terminal_to_right", "new_terminal_tab_to_right": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId) else { + result = .err(code: "not_found", message: "Tab pane not found", data: nil) + return + } + + let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + result = .err(code: "internal_error", message: "Failed to create tab", data: nil) + return + } + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + finish([ + "created_surface_id": newPanel.id.uuidString, + "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id) + ]) + + case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId) else { + result = .err(code: "not_found", message: "Tab pane not found", data: nil) + return + } + + let urlRaw = v2String(params, "url") + let url = urlRaw.flatMap { URL(string: $0) } + if urlRaw != nil && url == nil { + result = .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlRaw)]) + return + } + + let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + result = .err(code: "internal_error", message: "Failed to create tab", data: nil) + return + } + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + finish([ + "created_surface_id": newPanel.id.uuidString, + "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id) + ]) + + case "close_left", "close_to_left": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId) else { + result = .err(code: "not_found", message: "Tab pane not found", data: nil) + return + } + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { + result = .err(code: "not_found", message: "Tab not found in pane", data: nil) + return + } + let targetIds = Array(tabs.prefix(index).map(\.id)) + let closeResult = closeTabs(targetIds) + finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) + + case "close_right", "close_to_right": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId) else { + result = .err(code: "not_found", message: "Tab pane not found", data: nil) + return + } + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { + result = .err(code: "not_found", message: "Tab not found in pane", data: nil) + return + } + let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : [] + let closeResult = closeTabs(targetIds) + finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) + + case "close_others", "close_other_tabs": + guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), + let paneId = workspace.paneId(forPanelId: surfaceId) else { + result = .err(code: "not_found", message: "Tab pane not found", data: nil) + return + } + let targetIds = workspace.bonsplitController.tabs(inPane: paneId) + .map(\.id) + .filter { $0 != anchorTabId } + let closeResult = closeTabs(targetIds) + finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) + + default: + result = .err(code: "invalid_params", message: "Unknown tab action", data: [ + "action": action, + "supported_actions": supportedActions + ]) + } + } + + return result + } + // MARK: - V2 Surface Methods private func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? {