Add tab/workspace action APIs and consistent naming

This commit is contained in:
Lawrence Chen 2026-02-20 19:20:55 -08:00
parent dc2b3e506b
commit 10e44396df
3 changed files with 633 additions and 19 deletions

View file

@ -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 <name>")
}
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 <text> (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 <name>")
}
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 <text> (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 <name> [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 <name> Action name (required if not positional)
--workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)
--title <text> 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 <name> [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 <name> Action name (required if not positional)
--tab <id|ref|index> Target tab (alias: --surface)
--surface <id|ref|index> Alias for --tab
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--title <text> Title for rename
--url <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 <id>
move-workspace-to-window --workspace <id|ref> --window <id|ref>
reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>]
workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>]
list-workspaces
new-workspace [--command <text>]
new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
@ -4032,6 +4227,7 @@ struct CMUXCLI {
close-surface [--surface <id|ref>] [--workspace <id|ref>]
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>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
drag-surface-to-split --surface <id|ref> <left|right|up|down>
refresh-surfaces
surface-health [--workspace <id|ref>]

View file

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

View file

@ -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? {