diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 63536e6d..168540a2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 Action name (required if not positional) - --tab Target tab (alias: --surface) - --surface Alias for --tab + --tab Target tab (accepts tab: or surface:; alias: --surface) + --surface Alias for --tab (backward compatibility) --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 --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:` in addition to `surface:`. 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 ] [--workspace ] move-surface --surface [--pane ] [--workspace ] [--window ] [--before ] [--after ] [--index ] [--focus ] reorder-surface --surface (--index | --before | --after ) - tab-action --action [--tab ] [--workspace ] [--title ] [--url ] + tab-action --action [--tab ] [--surface ] [--workspace ] [--title ] [--url ] drag-surface-to-split --surface refresh-surfaces surface-health [--workspace ] @@ -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). """ diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 07291db3..7d1d489e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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": diff --git a/tests_v2/test_tab_workspace_action_naming.py b/tests_v2/test_tab_workspace_action_naming.py new file mode 100644 index 00000000..fd8014d2 --- /dev/null +++ b/tests_v2/test_tab_workspace_action_naming.py @@ -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:" in help_text, "tab-action --help should mention tab: 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())