tmux compat: implement issue-153 command set with matrix tests (#221)

* Add tmux rename-window workspace compatibility

Implement workspace.rename in the v2 API and wire CLI commands rename-workspace/rename-window with help text.

Add a regression test that validates API and CLI rename parity plus error handling.

Refs: https://github.com/manaflow-ai/cmux/issues/153

* Add full tmux compatibility command matrix and regression coverage
This commit is contained in:
Lawrence Chen 2026-02-20 18:22:26 -08:00 committed by GitHub
parent 6f1e100db6
commit 6cb282bf09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1440 additions and 0 deletions

View file

@ -667,6 +667,14 @@ class TerminalController {
return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params))
case "workspace.reorder":
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
case "workspace.rename":
return v2Result(id: id, self.v2WorkspaceRename(params: params))
case "workspace.next":
return v2Result(id: id, self.v2WorkspaceNext(params: params))
case "workspace.previous":
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(params: params))
// Surfaces / input
@ -696,6 +704,8 @@ class TerminalController {
return v2Result(id: id, self.v2SurfaceSendText(params: params))
case "surface.send_key":
return v2Result(id: id, self.v2SurfaceSendKey(params: params))
case "surface.clear_history":
return v2Result(id: id, self.v2SurfaceClearHistory(params: params))
case "surface.trigger_flash":
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
@ -708,6 +718,16 @@ class TerminalController {
return v2Result(id: id, self.v2PaneSurfaces(params: params))
case "pane.create":
return v2Result(id: id, self.v2PaneCreate(params: params))
case "pane.resize":
return v2Result(id: id, self.v2PaneResize(params: params))
case "pane.swap":
return v2Result(id: id, self.v2PaneSwap(params: params))
case "pane.break":
return v2Result(id: id, self.v2PaneBreak(params: params))
case "pane.join":
return v2Result(id: id, self.v2PaneJoin(params: params))
case "pane.last":
return v2Result(id: id, self.v2PaneLast(params: params))
// Notifications
case "notification.create":
@ -962,6 +982,10 @@ class TerminalController {
"workspace.close",
"workspace.move_to_window",
"workspace.reorder",
"workspace.rename",
"workspace.next",
"workspace.previous",
"workspace.last",
"surface.list",
"surface.current",
"surface.focus",
@ -976,11 +1000,17 @@ class TerminalController {
"surface.send_text",
"surface.send_key",
"surface.read_text",
"surface.clear_history",
"surface.trigger_flash",
"pane.list",
"pane.focus",
"pane.surfaces",
"pane.create",
"pane.resize",
"pane.swap",
"pane.break",
"pane.join",
"pane.last",
"notification.create",
"notification.create_for_surface",
"notification.create_for_target",
@ -1686,6 +1716,116 @@ class TerminalController {
"index": v2OrNull(newIndex)
])
}
private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspaceId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let titleRaw = v2String(params, "title"),
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
}
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
var renamed = false
v2MainSync {
guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return }
tabManager.setCustomTitle(tabId: workspaceId, title: title)
renamed = true
}
guard renamed else {
return .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)
])
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"title": title
])
}
private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
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)
}
tabManager.selectNextTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
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)
}
tabManager.selectPreviousTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
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)
}
tabManager.navigateBack()
guard let after = tabManager.selectedTabId, after != before else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": after.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: after),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
// MARK: - V2 Surface Methods
@ -2390,6 +2530,47 @@ class TerminalController {
return result
}
private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
guard terminalPanel.performBindingAction("clear_screen") else {
result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil)
return
}
terminalPanel.surface.forceRefresh()
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
@ -2725,6 +2906,269 @@ class TerminalController {
return result
}
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
let direction = (v2String(params, "direction") ?? "").lowercased()
let amount = v2Int(params, "amount") ?? 1
guard ["left", "right", "up", "down"].contains(direction), amount > 0 else {
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
}
return .err(
code: "not_supported",
message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API",
data: [
"direction": direction,
"amount": amount
]
)
}
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
guard let sourcePaneUUID = v2UUID(params, "pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
}
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
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
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
v2MainSync {
guard let located = v2LocatePane(sourcePaneUUID) else {
result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString])
return
}
guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else {
result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString])
return
}
let workspace = located.workspace
let sourcePane = located.paneId
guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane),
let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane),
let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id),
let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else {
result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil)
return
}
// Keep pane identities stable during swap when one side has a single surface.
var sourcePlaceholder: UUID?
var targetPlaceholder: UUID?
if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 {
sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id
if sourcePlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil)
return
}
}
if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 {
targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id
if targetPlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil)
return
}
}
guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil)
return
}
guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil)
return
}
if let sourcePlaceholder {
_ = workspace.closePanel(sourcePlaceholder, force: true)
}
if let targetPlaceholder {
_ = workspace.closePanel(targetPlaceholder, force: true)
}
if focus {
workspace.bonsplitController.focusPane(targetPane)
}
let windowId = located.windowId
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"pane_id": sourcePane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
"target_pane_id": targetPane.id.uuidString,
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id),
"source_surface_id": sourceSurfaceId.uuidString,
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
"target_surface_id": targetSurfaceId.uuidString,
"target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId)
])
}
return result
}
private func v2PaneBreak(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let focus = v2Bool(params, "focus") ?? true
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
v2MainSync {
guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let sourcePaneUUID = v2UUID(params, "pane_id")
let sourcePane: PaneID? = {
if let sourcePaneUUID {
return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID })
}
return sourceWorkspace.bonsplitController.focusedPaneId
}()
let surfaceId: UUID? = {
if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface }
if let sourcePane,
let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) {
return sourceWorkspace.panelIdFromSurfaceId(selected.id)
}
return sourceWorkspace.focusedPanelId
}()
guard let surfaceId else {
result = .err(code: "not_found", message: "No source surface to break", data: nil)
return
}
guard sourceWorkspace.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId)
guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else {
result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil)
return
}
let destinationWorkspace = tabManager.addWorkspace()
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: true
)
}
result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil)
return
}
guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: true
)
}
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),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": destinationWorkspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id),
"pane_id": destinationPane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
}
return result
}
private func v2PaneJoin(params: [String: Any]) -> V2CallResult {
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
var surfaceId = v2UUID(params, "surface_id")
if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") {
guard let sourceLocated = v2LocatePane(sourcePaneUUID),
let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId),
let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else {
return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [
"pane_id": sourcePaneUUID.uuidString
])
}
surfaceId = selectedSurface
}
guard let surfaceId else {
return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil)
}
var moveParams: [String: Any] = [
"surface_id": surfaceId.uuidString,
"pane_id": targetPaneUUID.uuidString
]
if let focus = v2Bool(params, "focus") {
moveParams["focus"] = focus
}
return v2SurfaceMove(params: moveParams)
}
private func v2PaneLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let focused = ws.bonsplitController.focusedPaneId else {
result = .err(code: "not_found", message: "No focused pane", data: nil)
return
}
guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else {
result = .err(code: "not_found", message: "No alternate pane available", data: nil)
return
}
ws.bonsplitController.focusPane(target)
let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) }
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": target.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: target.id),
"surface_id": v2OrNull(selectedSurfaceId?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId)
])
}
return result
}
// MARK: - V2 Notification Methods
private func v2NotificationCreate(params: [String: Any]) -> V2CallResult {