Unify tab/workspace action naming in CLI and socket

This commit is contained in:
Lawrence Chen 2026-02-20 19:29:40 -08:00
parent 10e44396df
commit a5360adb38
3 changed files with 265 additions and 18 deletions

View file

@ -1344,6 +1344,53 @@ struct CMUXCLI {
throw CLIError(message: "Surface index not found")
}
private func canonicalSurfaceHandleFromTabInput(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2,
String(pieces[0]).lowercased() == "tab",
let ordinal = Int(String(pieces[1])) else {
return trimmed
}
return "surface:\(ordinal)"
}
private func normalizeTabHandle(
_ raw: String?,
client: SocketClient,
workspaceHandle: String? = nil,
allowFocused: Bool = false
) throws -> String? {
guard let raw else {
return try normalizeSurfaceHandle(
nil,
client: client,
workspaceHandle: workspaceHandle,
allowFocused: allowFocused
)
}
let canonical = canonicalSurfaceHandleFromTabInput(raw)
return try normalizeSurfaceHandle(
canonical,
client: client,
workspaceHandle: workspaceHandle,
allowFocused: false
)
}
private func displayTabHandle(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2,
String(pieces[0]).lowercased() == "surface",
let ordinal = Int(String(pieces[1])) else {
return trimmed
}
return "tab:\(ordinal)"
}
private func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? {
let id = payload["\(kind)_id"] as? String
let ref = payload["\(kind)_ref"] as? String
@ -1360,6 +1407,40 @@ struct CMUXCLI {
}
}
private func formatTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? {
let id = (payload["tab_id"] as? String) ?? (payload["surface_id"] as? String)
let refRaw = (payload["tab_ref"] as? String) ?? (payload["surface_ref"] as? String)
let ref = displayTabHandle(refRaw)
switch idFormat {
case .refs:
return ref ?? id
case .uuids:
return id ?? ref
case .both:
if let ref, let id {
return "\(ref) (\(id))"
}
return ref ?? id
}
}
private func formatCreatedTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? {
let id = (payload["created_tab_id"] as? String) ?? (payload["created_surface_id"] as? String)
let refRaw = (payload["created_tab_ref"] as? String) ?? (payload["created_surface_ref"] as? String)
let ref = displayTabHandle(refRaw)
switch idFormat {
case .refs:
return ref ?? id
case .uuids:
return id ?? ref
case .both:
if let ref, let id {
return "\(ref) (\(id))"
}
return ref ?? id
}
}
private func printV2Payload(
_ payload: [String: Any],
jsonOutput: Bool,
@ -1591,10 +1672,14 @@ struct CMUXCLI {
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 tabArg = tabOpt
?? surfaceOpt
?? (workspaceOpt == nil && windowOverride == nil
? (ProcessInfo.processInfo.environment["CMUX_TAB_ID"] ?? 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 surfaceId = try normalizeTabHandle(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)
@ -1619,7 +1704,7 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "tab.action", params: params)
var summaryParts = ["OK", "action=\(action)"]
if let tabHandle = formatHandle(payload, kind: "surface", idFormat: idFormat) {
if let tabHandle = formatTabHandle(payload, idFormat: idFormat) {
summaryParts.append("tab=\(tabHandle)")
}
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) {
@ -1628,10 +1713,8 @@ struct CMUXCLI {
if let closed = payload["closed"] {
summaryParts.append("closed=\(closed)")
}
if let created = payload["created_surface_ref"] as? String {
if let created = formatCreatedTabHandle(payload, idFormat: idFormat) {
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: " "))
}
@ -2944,16 +3027,16 @@ struct CMUXCLI {
Flags:
--action <name> Action name (required if not positional)
--tab <id|ref|index> Target tab (alias: --surface)
--surface <id|ref|index> Alias for --tab
--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; alias: --surface)
--surface <id|ref|index> Alias for --tab (backward compatibility)
--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 --tab tab:3 --action pin
cmux tab-action --action close-right
cmux tab-action --tab surface:2 --action rename --title "build logs"
cmux tab-action --tab tab:2 --action rename --title "build logs"
"""
case "new-workspace":
return """
@ -4202,6 +4285,7 @@ struct CMUXCLI {
Handle Inputs:
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
`tab-action` also accepts `tab:<n>` in addition to `surface:<n>`.
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
Commands:
@ -4227,7 +4311,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>]
tab-action --action <name> [--tab <id|ref|index>] [--surface <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>]
@ -4318,6 +4402,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_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
"""

View file

@ -1193,6 +1193,8 @@ class TerminalController {
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"surface_id": v2OrNull(surfaceUUID?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: surfaceUUID),
"tab_id": v2OrNull(surfaceUUID?.uuidString),
"tab_ref": v2TabRef(uuid: surfaceUUID),
"surface_type": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType.rawValue }),
"is_browser_surface": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType == .browser })
]
@ -1208,7 +1210,7 @@ class TerminalController {
var resolvedCaller: [String: Any]? = nil
if let callerObj = params["caller"] as? [String: Any],
let wsId = v2UUIDAny(callerObj["workspace_id"]) {
let surfaceId = v2UUIDAny(callerObj["surface_id"])
let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"])
v2MainSync {
let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager
if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) {
@ -1224,6 +1226,8 @@ class TerminalController {
let paneUUID = ws.paneId(forPanelId: surfaceId)?.id
payload["surface_id"] = surfaceId.uuidString
payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId)
payload["tab_id"] = surfaceId.uuidString
payload["tab_ref"] = v2TabRef(uuid: surfaceId)
payload["surface_type"] = v2OrNull(ws.panels[surfaceId]?.panelType.rawValue)
payload["is_browser_surface"] = v2OrNull(ws.panels[surfaceId]?.panelType == .browser)
payload["pane_id"] = v2OrNull(paneUUID?.uuidString)
@ -1231,6 +1235,8 @@ class TerminalController {
} else {
payload["surface_id"] = NSNull()
payload["surface_ref"] = NSNull()
payload["tab_id"] = NSNull()
payload["tab_ref"] = NSNull()
payload["surface_type"] = NSNull()
payload["is_browser_surface"] = NSNull()
payload["pane_id"] = NSNull()
@ -1332,6 +1338,13 @@ class TerminalController {
return id
}
}
// Tab refs are aliases for surface refs in tab-facing APIs.
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("tab:"),
let ordinal = Int(trimmed.replacingOccurrences(of: "tab:", with: "")),
let id = v2UUIDByRef[.surface]?["surface:\(ordinal)"] {
return id
}
return nil
}
@ -1340,6 +1353,12 @@ class TerminalController {
return v2EnsureHandleRef(kind: kind, uuid: uuid)
}
private func v2TabRef(uuid: UUID?) -> Any {
guard let uuid else { return NSNull() }
let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: uuid)
return surfaceRef.replacingOccurrences(of: "surface:", with: "tab:")
}
private func v2RefreshKnownRefs() {
guard let app = AppDelegate.shared else { return }
@ -2041,7 +2060,7 @@ class TerminalController {
return
}
let surfaceId = v2UUID(params, "surface_id") ?? workspace.focusedPanelId
let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused tab", data: nil)
return
@ -2049,7 +2068,9 @@ class TerminalController {
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)
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"tab_id": surfaceId.uuidString,
"tab_ref": v2TabRef(uuid: surfaceId)
])
return
}
@ -2065,7 +2086,9 @@ class TerminalController {
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"tab_id": surfaceId.uuidString,
"tab_ref": v2TabRef(uuid: surfaceId)
]
if let paneId = workspace.paneId(forPanelId: surfaceId)?.id {
payload["pane_id"] = paneId.uuidString
@ -2169,7 +2192,9 @@ class TerminalController {
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "new_terminal_right", "new_terminal_to_right", "new_terminal_tab_to_right":
@ -2187,7 +2212,9 @@ class TerminalController {
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right":
@ -2212,7 +2239,9 @@ class TerminalController {
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "close_left", "close_to_left":

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Regression: tab/workspace action naming is consistent in CLI + socket v2."""
import glob
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, 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], json_output: bool) -> 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]
if json_output:
cmd.append("--json")
cmd.extend(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
def _run_cli_json(cli: str, args: List[str]) -> Dict:
output = _run_cli(cli, args, json_output=True)
try:
return json.loads(output or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})")
def _focused_surface_ref(c: cmux, workspace_id: str) -> str:
current = c._call("surface.current", {"workspace_id": workspace_id}) or {}
surface_ref = str(current.get("surface_ref") or "")
if surface_ref.startswith("surface:"):
return surface_ref
listed = c._call("surface.list", {"workspace_id": workspace_id}) or {}
rows = listed.get("surfaces") or []
for row in rows:
if bool(row.get("focused")):
ref = str(row.get("ref") or "")
if ref.startswith("surface:"):
return ref
for row in rows:
ref = str(row.get("ref") or "")
if ref.startswith("surface:"):
return ref
raise cmuxError(f"Unable to resolve focused surface ref in workspace {workspace_id}: {listed}")
def main() -> int:
cli = _find_cli_binary()
help_text = _run_cli(cli, ["tab-action", "--help"], json_output=False)
_must("Target tab" in help_text, "tab-action --help should describe tab target naming")
_must("tab:<n>" in help_text, "tab-action --help should mention tab:<n> refs")
_must("--tab tab:" in help_text, "tab-action examples should use tab: refs")
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
for method in ["workspace.action", "tab.action", "surface.action"]:
_must(method in methods, f"Missing method in capabilities: {method}")
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}")
try:
c._call("workspace.select", {"workspace_id": ws_id})
surface_ref = _focused_surface_ref(c, ws_id)
tab_ref = "tab:" + surface_ref.split(":", 1)[1]
pin = _run_cli_json(cli, ["tab-action", "--workspace", ws_id, "--tab", tab_ref, "--action", "pin"])
_must(str(pin.get("tab_ref") or "").startswith("tab:"), f"Expected tab_ref in tab-action payload: {pin}")
_must(bool(pin.get("pinned")) is True, f"tab-action pin should report pinned=true: {pin}")
unpin = _run_cli_json(cli, ["tab-action", "--workspace", ws_id, "--tab", tab_ref, "--action", "unpin"])
_must(bool(unpin.get("pinned")) is False, f"tab-action unpin should report pinned=false: {unpin}")
socket_tab = c._call("tab.action", {"workspace_id": ws_id, "tab_id": tab_ref, "action": "clear_name"}) or {}
_must(str(socket_tab.get("tab_ref") or "").startswith("tab:"), f"Expected tab_ref in tab.action result: {socket_tab}")
_must(str(socket_tab.get("workspace_id") or "") == ws_id, f"tab.action should target requested workspace: {socket_tab}")
finally:
try:
c.close_workspace(ws_id)
except Exception:
pass
print("PASS: tab/workspace naming stays consistent across tab-action CLI and socket APIs")
return 0
if __name__ == "__main__":
raise SystemExit(main())