diff --git a/CLI/cmux.swift b/CLI/cmux.swift index be1d512b..2c08731f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7,19 +7,6 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } -struct WorkspaceInfo { - let index: Int - let id: String - let title: String - let selected: Bool -} - -struct PanelInfo { - let index: Int - let id: String - let focused: Bool -} - struct WindowInfo { let index: Int let id: String @@ -28,27 +15,6 @@ struct WindowInfo { let workspaceCount: Int } -struct PaneInfo { - let index: Int - let id: String - let focused: Bool - let tabCount: Int -} - -struct PaneSurfaceInfo { - let index: Int - let title: String - let panelId: String - let selected: Bool -} - -struct SurfaceHealthInfo { - let index: Int - let id: String - let surfaceType: String - let inWindow: Bool? -} - struct NotificationInfo { let id: String let workspaceId: String @@ -501,8 +467,9 @@ struct CMUXCLI { var params: [String: Any] = [:] let includeCaller = !hasFlag(commandArgs, name: "--no-caller") if includeCaller { - let workspaceArg = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + let idWsFlag = optionValue(commandArgs, name: "--workspace") + let workspaceArg = idWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (idWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) if workspaceArg != nil || surfaceArg != nil { let workspaceId = try normalizeWorkspaceHandle( workspaceArg, @@ -577,15 +544,19 @@ struct CMUXCLI { print(response) case "move-workspace-to-window": - guard let workspace = optionValue(commandArgs, name: "--workspace") else { + guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "move-workspace-to-window requires --workspace") } - guard let target = optionValue(commandArgs, name: "--window") else { + guard let windowRaw = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "move-workspace-to-window requires --window") } - let wsId = try resolveWorkspaceId(workspace, client: client) - let response = try client.send(command: "move_workspace_to_window \(wsId) \(target)") - print(response) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) + if let wsId { params["workspace_id"] = wsId } + let winId = try normalizeWindowHandle(windowRaw, client: client) + if let winId { params["window_id"] = winId } + let payload = try client.sendV2(method: "workspace.move_to_window", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace", "window"])) case "move-surface": try runMoveSurface(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) @@ -597,18 +568,24 @@ struct CMUXCLI { try runReorderWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "list-workspaces": - let response = try client.send(command: "list_workspaces") + let payload = try client.sendV2(method: "workspace.list") if jsonOutput { - let workspaces = parseWorkspaces(response) - let payload = workspaces.map { [ - "index": $0.index, - "id": $0.id, - "title": $0.title, - "selected": $0.selected - ] } - print(jsonString(payload)) + print(jsonString(formatIDs(payload, mode: idFormat))) } else { - print(response) + let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] + if workspaces.isEmpty { + print("No workspaces") + } else { + for ws in workspaces { + let selected = (ws["selected"] as? Bool) == true + let handle = textHandle(ws, idFormat: idFormat) + let title = (ws["title"] as? String) ?? "" + let prefix = selected ? "* " : " " + let selTag = selected ? " [selected]" : "" + let titlePart = title.isEmpty ? "" : " \(title)" + print("\(prefix)\(handle)\(titlePart)\(selTag)") + } + } } case "new-workspace": @@ -616,82 +593,125 @@ struct CMUXCLI { print(response) case "new-split": - let (panelArg, remaining) = parseOption(commandArgs, name: "--panel") - guard let direction = remaining.first else { + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (panelArg, rem1) = parseOption(rem0, name: "--panel") + let (sfArg, rem2) = parseOption(rem1, name: "--surface") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceRaw = sfArg ?? panelArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + guard let direction = rem2.first else { throw CLIError(message: "new-split requires a direction") } - let cmd = panelArg != nil ? "new_split \(direction) \(panelArg!)" : "new_split \(direction)" - let response = try client.send(command: cmd) - print(response) + var params: [String: Any] = ["direction": direction] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.split", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "list-panes": - let response = try client.send(command: "list_panes") + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "pane.list", params: params) if jsonOutput { - let panes = parsePanes(response) - let payload = panes.map { [ - "index": $0.index, - "id": $0.id, - "focused": $0.focused, - "tab_count": $0.tabCount - ] } - print(jsonString(payload)) + print(jsonString(formatIDs(payload, mode: idFormat))) } else { - print(response) + let panes = payload["panes"] as? [[String: Any]] ?? [] + if panes.isEmpty { + print("No panes") + } else { + for pane in panes { + let focused = (pane["focused"] as? Bool) == true + let handle = textHandle(pane, idFormat: idFormat) + let count = pane["surface_count"] as? Int ?? 0 + let prefix = focused ? "* " : " " + let focusTag = focused ? " [focused]" : "" + print("\(prefix)\(handle) [\(count) surface\(count == 1 ? "" : "s")]\(focusTag)") + } + } } case "list-pane-surfaces": - let pane = optionValue(commandArgs, name: "--pane") - let cmd = pane != nil ? "list_pane_surfaces --pane=\(pane!)" : "list_pane_surfaces" - let response = try client.send(command: cmd) + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) + let paneRaw = optionValue(commandArgs, name: "--pane") + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let payload = try client.sendV2(method: "pane.surfaces", params: params) if jsonOutput { - let surfaces = parsePaneSurfaces(response) - let payload = surfaces.map { [ - "index": $0.index, - "title": $0.title, - "id": $0.panelId, - "selected": $0.selected - ] } - print(jsonString(payload)) + print(jsonString(formatIDs(payload, mode: idFormat))) } else { - print(response) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces in pane") + } else { + for surface in surfaces { + let selected = (surface["selected"] as? Bool) == true + let handle = textHandle(surface, idFormat: idFormat) + let title = (surface["title"] as? String) ?? "" + let prefix = selected ? "* " : " " + let selTag = selected ? " [selected]" : "" + print("\(prefix)\(handle) \(title)\(selTag)") + } + } } case "focus-pane": - guard let pane = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else { - throw CLIError(message: "focus-pane requires --pane ") + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) + guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else { + throw CLIError(message: "focus-pane requires --pane ") } - let response = try client.send(command: "focus_pane \(pane)") - print(response) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let payload = try client.sendV2(method: "pane.focus", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane", "workspace"])) case "new-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) let type = optionValue(commandArgs, name: "--type") - let direction = optionValue(commandArgs, name: "--direction") + let direction = optionValue(commandArgs, name: "--direction") ?? "right" let url = optionValue(commandArgs, name: "--url") - var args: [String] = [] - if let type { args.append("--type=\(type)") } - if let direction { args.append("--direction=\(direction)") } - if let url { args.append("--url=\(url)") } - let cmd = args.isEmpty ? "new_pane" : "new_pane \(args.joined(separator: " "))" - let response = try client.send(command: cmd) - print(formatLegacySurfaceResponse(response, client: client, idFormat: idFormat)) + var params: [String: Any] = ["direction": direction] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + if let type { params["type"] = type } + if let url { params["url"] = url } + let payload = try client.sendV2(method: "pane.create", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "new-surface": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) let type = optionValue(commandArgs, name: "--type") - let pane = optionValue(commandArgs, name: "--pane") + let paneRaw = optionValue(commandArgs, name: "--pane") let url = optionValue(commandArgs, name: "--url") - var args: [String] = [] - if let type { args.append("--type=\(type)") } - if let pane { args.append("--pane=\(pane)") } - if let url { args.append("--url=\(url)") } - let cmd = args.isEmpty ? "new_surface" : "new_surface \(args.joined(separator: " "))" - let response = try client.send(command: cmd) - print(formatLegacySurfaceResponse(response, client: client, idFormat: idFormat)) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + if let type { params["type"] = type } + if let url { params["url"] = url } + let payload = try client.sendV2(method: "surface.create", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "close-surface": - let surface = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") - let cmd = surface != nil ? "close_surface \(surface!)" : "close_surface" - let response = try client.send(command: cmd) - print(response) + let csWsFlag = optionValue(commandArgs, name: "--workspace") + let workspaceArg = csWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (csWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.close", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "drag-surface-to-split": let (surfaceArg, rem0) = parseOption(commandArgs, name: "--surface") @@ -711,98 +731,103 @@ struct CMUXCLI { print(response) case "surface-health": - let workspace = optionValue(commandArgs, name: "--workspace") - let cmd = workspace != nil ? "surface_health \(workspace!)" : "surface_health" - let response = try client.send(command: cmd) - if jsonOutput { - let rows = parseSurfaceHealth(response) - let payload = rows.map { row -> [String: Any] in - var item: [String: Any] = [ - "index": row.index, - "id": row.id, - "type": row.surfaceType, - ] - item["in_window"] = row.inWindow ?? NSNull() - return item - } - print(jsonString(payload)) - } else { - print(response) - } - - case "trigger-flash": - let workspaceArg = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) var params: [String: Any] = [:] - var workspaceId: String? - if workspaceArg != nil || surfaceArg != nil { - workspaceId = try resolveWorkspaceId(workspaceArg, client: client) - if let workspaceId { - params["workspace_id"] = workspaceId - } - } - if let surfaceArg { - let ws: String - if let workspaceId { - ws = workspaceId - } else { - ws = try resolveWorkspaceId(nil, client: client) - } - let surfaceId = try resolveSurfaceId(surfaceArg, workspaceId: ws, client: client) - params["workspace_id"] = ws - params["surface_id"] = surfaceId - } - let payload = try client.sendV2(method: "surface.trigger_flash", params: params) + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "surface.health", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { - let sid = formatHandle(payload, kind: "surface", idFormat: idFormat) - let ws = formatHandle(payload, kind: "workspace", idFormat: idFormat) - if let sid, let ws { - print("OK \(sid) \(ws)") - } else if let sid { - print("OK \(sid)") + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces") } else { - print("OK") + for surface in surfaces { + let handle = textHandle(surface, idFormat: idFormat) + let sType = (surface["type"] as? String) ?? "" + let inWindow = surface["in_window"] + let inWindowStr: String + if let b = inWindow as? Bool { + inWindowStr = " in_window=\(b)" + } else { + inWindowStr = "" + } + print("\(handle) type=\(sType)\(inWindowStr)") + } } } + case "trigger-flash": + let tfWsFlag = optionValue(commandArgs, name: "--workspace") + let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.trigger_flash", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) + case "list-panels": - let (workspaceArg, _) = parseOption(commandArgs, name: "--workspace") - let response = try client.send(command: "list_surfaces \(workspaceArg ?? "")".trimmingCharacters(in: .whitespaces)) + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "surface.list", params: params) if jsonOutput { - let panels = parsePanels(response) - let payload = panels.map { [ - "index": $0.index, - "id": $0.id, - "focused": $0.focused - ] } - print(jsonString(payload)) + print(jsonString(formatIDs(payload, mode: idFormat))) } else { - print(response) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces") + } else { + for surface in surfaces { + let focused = (surface["focused"] as? Bool) == true + let handle = textHandle(surface, idFormat: idFormat) + let sType = (surface["type"] as? String) ?? "" + let title = (surface["title"] as? String) ?? "" + let prefix = focused ? "* " : " " + let focusTag = focused ? " [focused]" : "" + let titlePart = title.isEmpty ? "" : " \"\(title)\"" + print("\(prefix)\(handle) \(sType)\(focusTag)\(titlePart)") + } + } } case "focus-panel": - guard let panel = optionValue(commandArgs, name: "--panel") else { + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) + guard let panelRaw = optionValue(commandArgs, name: "--panel") else { throw CLIError(message: "focus-panel requires --panel") } - let response = try client.send(command: "focus_surface \(panel)") - print(response) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(panelRaw, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.focus", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "close-workspace": - guard let workspace = optionValue(commandArgs, name: "--workspace") else { + guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "close-workspace requires --workspace") } - let workspaceId = try resolveWorkspaceId(workspace, client: client) - let response = try client.send(command: "close_workspace \(workspaceId)") - print(response) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "workspace.close", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "select-workspace": - guard let workspace = optionValue(commandArgs, name: "--workspace") else { + guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "select-workspace requires --workspace") } - let response = try client.send(command: "select_workspace \(workspace)") - print(response) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "workspace.select", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": let response = try client.send(command: "current_workspace") @@ -813,43 +838,80 @@ struct CMUXCLI { } case "send": - let text = commandArgs.joined(separator: " ") - guard !text.isEmpty else { throw CLIError(message: "send requires text") } - let escaped = escapeText(text) - let response = try client.send(command: "send \(escaped)") - print(response) + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") + guard !rawText.isEmpty else { throw CLIError(message: "send requires text") } + let text = unescapeSendText(rawText) + var params: [String: Any] = ["text": text] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-key": - guard let key = commandArgs.first else { throw CLIError(message: "send-key requires a key") } - let response = try client.send(command: "send_key \(key)") - print(response) + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let keyArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 + guard let key = keyArgs.first else { throw CLIError(message: "send-key requires a key") } + var params: [String: Any] = ["key": key] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_key", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-panel": - guard let panel = optionValue(commandArgs, name: "--panel") else { + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (panelArg, rem1) = parseOption(rem0, name: "--panel") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + guard let panelArg else { throw CLIError(message: "send-panel requires --panel") } - let text = remainingArgs(commandArgs, removing: ["--panel", panel]).joined(separator: " ") - guard !text.isEmpty else { throw CLIError(message: "send-panel requires text") } - let escaped = escapeText(text) - let response = try client.send(command: "send_surface \(panel) \(escaped)") - print(response) + let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") + guard !rawText.isEmpty else { throw CLIError(message: "send-panel requires text") } + let text = unescapeSendText(rawText) + var params: [String: Any] = ["text": text] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-key-panel": - guard let panel = optionValue(commandArgs, name: "--panel") else { + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (panelArg, rem1) = parseOption(rem0, name: "--panel") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + guard let panelArg else { throw CLIError(message: "send-key-panel requires --panel") } - let key = remainingArgs(commandArgs, removing: ["--panel", panel]).first ?? "" + let skpArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 + let key = skpArgs.first ?? "" guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") } - let response = try client.send(command: "send_key_surface \(panel) \(key)") - print(response) + var params: [String: Any] = ["key": key] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_key", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "notify": let title = optionValue(commandArgs, name: "--title") ?? "Notification" let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" - let workspaceArg = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + let notifyWsFlag = optionValue(commandArgs, name: "--workspace") + let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client) let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) @@ -970,6 +1032,12 @@ struct CMUXCLI { out.removeValue(forKey: key) } } + for key in keys where key.hasSuffix("_ids") { + let prefix = String(key.dropLast(4)) + if out["\(prefix)_refs"] != nil { + out.removeValue(forKey: key) + } + } case .uuids: if out["id"] != nil && out["ref"] != nil { out.removeValue(forKey: "ref") @@ -981,6 +1049,12 @@ struct CMUXCLI { out.removeValue(forKey: key) } } + for key in keys where key.hasSuffix("_refs") { + let prefix = String(key.dropLast(5)) + if out["\(prefix)_ids"] != nil { + out.removeValue(forKey: key) + } + } } return out @@ -1039,7 +1113,7 @@ struct CMUXCLI { return trimmed } guard let wantedIndex = Int(trimmed) else { - return trimmed + throw CLIError(message: "Invalid window handle: \(trimmed) (expected UUID, ref like window:1, or index)") } let listed = try client.sendV2(method: "window.list") @@ -1068,7 +1142,7 @@ struct CMUXCLI { return trimmed } guard let wantedIndex = Int(trimmed) else { - return trimmed + throw CLIError(message: "Invalid workspace handle: \(trimmed) (expected UUID, ref like workspace:1, or index)") } var params: [String: Any] = [:] @@ -1102,7 +1176,7 @@ struct CMUXCLI { return trimmed } guard let wantedIndex = Int(trimmed) else { - return trimmed + throw CLIError(message: "Invalid pane handle: \(trimmed) (expected UUID, ref like pane:1, or index)") } var params: [String: Any] = [:] @@ -1136,7 +1210,7 @@ struct CMUXCLI { return trimmed } guard let wantedIndex = Int(trimmed) else { - return trimmed + throw CLIError(message: "Invalid surface handle: \(trimmed) (expected UUID, ref like surface:1, or index)") } var params: [String: Any] = [:] @@ -1167,40 +1241,6 @@ struct CMUXCLI { } } - private func formatLegacySurfaceResponse(_ response: String, client: SocketClient, idFormat: CLIIDFormat) -> String { - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("OK ") else { return response } - - let suffix = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - guard isUUID(suffix), idFormat != .uuids else { return response } - - do { - let listed = try client.sendV2(method: "surface.list") - let surfaces = listed["surfaces"] as? [[String: Any]] ?? [] - guard let row = surfaces.first(where: { ($0["id"] as? String) == suffix }) else { - return response - } - - let ref = row["ref"] as? String - let rendered: String - switch idFormat { - case .refs: - rendered = ref ?? suffix - case .uuids: - rendered = suffix - case .both: - if let ref { - rendered = "\(ref) (\(suffix))" - } else { - rendered = suffix - } - } - return "OK \(rendered)" - } catch { - return response - } - } - private func printV2Payload( _ payload: [String: Any], jsonOutput: Bool, @@ -1433,7 +1473,7 @@ struct CMUXCLI { if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) { params["surface_id"] = sourceSurface } - let workspaceRaw = workspaceOpt ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) if let workspaceRaw { if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { params["workspace_id"] = workspace @@ -2383,41 +2423,6 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser subcommand: \(subcommand)") } - private func parseWorkspaces(_ response: String) -> [WorkspaceInfo] { - guard response != "No workspaces" else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - let raw = String(line) - let selected = raw.hasPrefix("*") - let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* ")) - let parts = cleaned.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) - guard parts.count >= 2 else { return nil } - let indexText = parts[0].replacingOccurrences(of: ":", with: "") - guard let index = Int(indexText) else { return nil } - let id = String(parts[1]) - let title = parts.count > 2 ? String(parts[2]) : "" - return WorkspaceInfo(index: index, id: id, title: title, selected: selected) - } - } - - private func parsePanels(_ response: String) -> [PanelInfo] { - guard response != "No surfaces" else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - let raw = String(line) - let focused = raw.hasPrefix("*") - let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* ")) - let parts = cleaned.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) - guard parts.count >= 2 else { return nil } - let indexText = parts[0].replacingOccurrences(of: ":", with: "") - guard let index = Int(indexText) else { return nil } - let id = String(parts[1]) - return PanelInfo(index: index, id: id, focused: focused) - } - } - private func parseWindows(_ response: String) -> [WindowInfo] { guard response != "No windows" else { return [] } return response @@ -2454,103 +2459,6 @@ struct CMUXCLI { } } - private func parsePanes(_ response: String) -> [PaneInfo] { - guard response != "No panes" else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - let raw = String(line) - let focused = raw.hasPrefix("*") - let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* ")) - let parts = cleaned.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) - guard parts.count >= 2 else { return nil } - - let indexText = parts[0].replacingOccurrences(of: ":", with: "") - guard let index = Int(indexText) else { return nil } - let id = String(parts[1]) - - var tabCount = 0 - if parts.count >= 3 { - let trailing = String(parts[2]) - if let open = trailing.firstIndex(of: "["), - let close = trailing.firstIndex(of: "]"), - open < close { - let inside = trailing[trailing.index(after: open).. [PaneSurfaceInfo] { - guard response != "No tabs in pane" else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - let raw = String(line) - let selected = raw.hasPrefix("*") - let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* ")) - - guard let firstSpace = cleaned.firstIndex(of: " ") else { return nil } - let indexToken = cleaned[.. [SurfaceHealthInfo] { - guard response != "No surfaces" else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - let raw = String(line) - let parts = raw.split(separator: " ").map(String.init) - guard parts.count >= 4 else { return nil } - - let indexText = parts[0].replacingOccurrences(of: ":", with: "") - guard let index = Int(indexText) else { return nil } - let id = parts[1] - - var surfaceType = "" - var inWindow: Bool? - for token in parts.dropFirst(2) { - if token.hasPrefix("type=") { - surfaceType = token.replacingOccurrences(of: "type=", with: "") - } else if token.hasPrefix("in_window=") { - let value = token.replacingOccurrences(of: "in_window=", with: "") - if value == "true" { - inWindow = true - } else if value == "false" { - inWindow = false - } else { - inWindow = nil - } - } - } - - return SurfaceHealthInfo(index: index, id: id, surfaceType: surfaceType, inWindow: inWindow) - } - } - private func parseNotifications(_ response: String) -> [NotificationInfo] { guard response != "No notifications" else { return [] } return response @@ -2585,43 +2493,60 @@ struct CMUXCLI { if let raw, isUUID(raw) { return raw } + if let raw, isHandleRef(raw) { + // Resolve ref to UUID — search across all windows + let windows = try client.sendV2(method: "window.list") + let windowList = windows["windows"] as? [[String: Any]] ?? [] + for window in windowList { + guard let windowId = window["id"] as? String else { continue } + let listed = try client.sendV2(method: "workspace.list", params: ["window_id": windowId]) + let items = listed["workspaces"] as? [[String: Any]] ?? [] + for item in items where (item["ref"] as? String) == raw { + if let id = item["id"] as? String { return id } + } + } + throw CLIError(message: "Workspace ref not found: \(raw)") + } if let raw, let index = Int(raw) { - let response = try client.send(command: "list_workspaces") - let workspaces = parseWorkspaces(response) - if let match = workspaces.first(where: { $0.index == index }) { - return match.id + let listed = try client.sendV2(method: "workspace.list") + let items = listed["workspaces"] as? [[String: Any]] ?? [] + for item in items where intFromAny(item["index"]) == index { + if let id = item["id"] as? String { return id } } throw CLIError(message: "Workspace index not found") } - let response = try client.send(command: "current_workspace") - if response.hasPrefix("ERROR") { - throw CLIError(message: response) - } - return response + let current = try client.sendV2(method: "workspace.current") + if let wsId = current["workspace_id"] as? String { return wsId } + throw CLIError(message: "No workspace selected") } private func resolveSurfaceId(_ raw: String?, workspaceId: String, client: SocketClient) throws -> String { if let raw, isUUID(raw) { return raw } - - let response = try client.send(command: "list_surfaces \(workspaceId)") - if response.hasPrefix("ERROR") { - throw CLIError(message: response) + if let raw, isHandleRef(raw) { + let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let items = listed["surfaces"] as? [[String: Any]] ?? [] + for item in items where (item["ref"] as? String) == raw { + if let id = item["id"] as? String { return id } + } + throw CLIError(message: "Surface ref not found: \(raw)") } - let panels = parsePanels(response) + + let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let items = listed["surfaces"] as? [[String: Any]] ?? [] if let raw, let index = Int(raw) { - if let match = panels.first(where: { $0.index == index }) { - return match.id + for item in items where intFromAny(item["index"]) == index { + if let id = item["id"] as? String { return id } } throw CLIError(message: "Surface index not found") } - if let focused = panels.first(where: { $0.focused }) { - return focused.id + if let focused = items.first(where: { ($0["focused"] as? Bool) == true }) { + if let id = focused["id"] as? String { return id } } throw CLIError(message: "Unable to resolve surface ID") @@ -2631,12 +2556,18 @@ struct CMUXCLI { var remaining: [String] = [] var value: String? var skipNext = false + var pastTerminator = false for (idx, arg) in args.enumerated() { if skipNext { skipNext = false continue } - if arg == name, idx + 1 < args.count { + if arg == "--" { + pastTerminator = true + remaining.append(arg) + continue + } + if !pastTerminator, arg == name, idx + 1 < args.count { value = args[idx + 1] skipNext = true continue @@ -2659,22 +2590,41 @@ struct CMUXCLI { args.map { $0 == from ? to : $0 } } - private func remainingArgs(_ args: [String], removing tokens: [String]) -> [String] { - var remaining = args - for token in tokens { - if let index = remaining.firstIndex(of: token) { - remaining.remove(at: index) - } - } - return remaining + /// Unescape CLI escape sequences to match legacy v1 send behavior. + /// \n and \r → carriage return (Enter), \t → tab. + private func unescapeSendText(_ text: String) -> String { + return text + .replacingOccurrences(of: "\\n", with: "\r") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") } - private func escapeText(_ text: String) -> String { - return text - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") + private func workspaceFromArgsOrEnv(_ args: [String], windowOverride: String? = nil) -> String? { + if let explicit = optionValue(args, name: "--workspace") { return explicit } + // When --window is explicitly targeted, don't fall back to env workspace from a different window + if windowOverride != nil { return nil } + return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + } + + /// Pick the display handle for an item dict based on --id-format. + private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { + let ref = item["ref"] as? String + let id = item["id"] as? String + switch idFormat { + case .refs: return ref ?? id ?? "?" + case .uuids: return id ?? ref ?? "?" + case .both: return [ref, id].compactMap({ $0 }).joined(separator: " ") + } + } + + private func v2OKSummary(_ payload: [String: Any], idFormat: CLIIDFormat, kinds: [String] = ["surface", "workspace"]) -> String { + var parts = ["OK"] + for kind in kinds { + if let handle = formatHandle(payload, kind: kind, idFormat: idFormat) { + parts.append(handle) + } + } + return parts.joined(separator: " ") } private func isUUID(_ value: String) -> Bool { @@ -2693,8 +2643,9 @@ struct CMUXCLI { private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) - let workspaceArg = optionValue(hookArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] - let surfaceArg = optionValue(hookArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + let hookWsFlag = optionValue(hookArgs, name: "--workspace") + let workspaceArg = hookWsFlag ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() @@ -2828,8 +2779,8 @@ struct CMUXCLI { private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { - let probe = try? client.send(command: "list_surfaces \(candidate)") - if let probe, !probe.hasPrefix("ERROR") { + let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) + if probe != nil { return candidate } } @@ -3105,36 +3056,36 @@ struct CMUXCLI { new-window focus-window --window close-window --window - move-workspace-to-window --workspace --window + move-workspace-to-window --workspace --window reorder-workspace --workspace (--index | --before | --after ) [--window ] list-workspaces new-workspace - new-split [--panel ] - list-panes - list-pane-surfaces [--pane ] - focus-pane --pane - new-pane [--type ] [--direction ] [--url ] - new-surface [--type ] [--pane ] [--url ] - close-surface [--surface ] + new-split [--workspace ] [--surface ] [--panel ] + list-panes [--workspace ] + list-pane-surfaces [--workspace ] [--pane ] + focus-pane --pane [--workspace ] + new-pane [--type ] [--direction ] [--workspace ] [--url ] + new-surface [--type ] [--pane ] [--workspace ] [--url ] + close-surface [--surface ] [--workspace ] move-surface --surface [--pane ] [--workspace ] [--window ] [--before ] [--after ] [--index ] [--focus ] reorder-surface --surface (--index | --before | --after ) - drag-surface-to-split --surface + drag-surface-to-split --surface refresh-surfaces - surface-health [--workspace ] - trigger-flash [--workspace ] [--surface ] - list-panels [--workspace ] - focus-panel --panel - close-workspace --workspace - select-workspace --workspace + surface-health [--workspace ] + trigger-flash [--workspace ] [--surface ] + list-panels [--workspace ] + focus-panel --panel [--workspace ] + close-workspace --workspace + select-workspace --workspace current-workspace - send - send-key - send-panel --panel - send-key-panel --panel - notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] + send [--workspace ] [--surface ] + send-key [--workspace ] [--surface ] + send-panel --panel [--workspace ] + send-key-panel --panel [--workspace ] + notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] list-notifications clear-notifications - claude-hook [--workspace ] [--surface ] + claude-hook [--workspace ] [--surface ] set-app-focus simulate-app-active @@ -3183,7 +3134,7 @@ struct CMUXCLI { Environment: CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for - browser open, new-surface, notify, and other commands. + ALL commands (send, list-panels, new-split, notify, etc.). 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/tests_v2/test_cli_id_format_defaults.py b/tests_v2/test_cli_id_format_defaults.py index ca2aa0a0..781aa91e 100755 --- a/tests_v2/test_cli_id_format_defaults.py +++ b/tests_v2/test_cli_id_format_defaults.py @@ -110,6 +110,60 @@ def main() -> int: ) print("PASS: CLI id-format defaults are refs-first (with both/uuids opt-in working)") + + # ------------------------------------------------------------------ + # Verify migrated list commands also respect id-format + # ------------------------------------------------------------------ + + # list-panels + panels_default = _run_cli_json(cli, ["list-panels"]) + surfaces = panels_default.get("surfaces", []) + if surfaces: + _must( + _has_any_key(panels_default, lambda k: k.endswith("_ref") or k == "ref"), + f"list-panels default should include refs: {panels_default}", + ) + _must( + len(_id_ref_pairs(panels_default)) == 0, + f"list-panels default should suppress id when ref exists; pairs={_id_ref_pairs(panels_default)}", + ) + + panels_both = _run_cli_json(cli, ["list-panels"], extra_flags=["--id-format", "both"]) + if panels_both.get("surfaces"): + _must(len(_id_ref_pairs(panels_both)) > 0, f"list-panels --id-format both should include pairs: {panels_both}") + + # list-panes + panes_default = _run_cli_json(cli, ["list-panes"]) + panes = panes_default.get("panes", []) + if panes: + _must( + _has_any_key(panes_default, lambda k: k.endswith("_ref") or k == "ref"), + f"list-panes default should include refs: {panes_default}", + ) + + # list-workspaces + ws_default = _run_cli_json(cli, ["list-workspaces"]) + workspaces = ws_default.get("workspaces", []) + if workspaces: + _must( + _has_any_key(ws_default, lambda k: k.endswith("_ref") or k == "ref"), + f"list-workspaces default should include refs: {ws_default}", + ) + _must( + len(_id_ref_pairs(ws_default)) == 0, + f"list-workspaces default should suppress id when ref exists; pairs={_id_ref_pairs(ws_default)}", + ) + + # surface-health + health_default = _run_cli_json(cli, ["surface-health"]) + health_surfaces = health_default.get("surfaces", []) + if health_surfaces: + _must( + _has_any_key(health_default, lambda k: k.endswith("_ref") or k == "ref"), + f"surface-health default should include refs: {health_default}", + ) + + print("PASS: Migrated list commands also respect id-format defaults") return 0 diff --git a/tests_v2/test_workspace_relative.py b/tests_v2/test_workspace_relative.py new file mode 100644 index 00000000..d6fdf17b --- /dev/null +++ b/tests_v2/test_workspace_relative.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Regression: CLI commands are workspace-relative via CMUX_WORKSPACE_ID. + +Tests that when CMUX_WORKSPACE_ID is set, CLI commands target that workspace +(not the focused workspace). This is the core P0 #2 behavior: agents in +background workspaces should not affect the user's active workspace. +""" + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +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], env_overrides: Optional[Dict[str, str]] = None) -> str: + """Run CLI command and return stdout.""" + cmd = [cli, "--socket", SOCKET_PATH] + args + env = os.environ.copy() + if env_overrides: + env.update(env_overrides) + 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.strip() + + +def _run_cli_json(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> Any: + """Run CLI command with --json and return parsed output.""" + output = _run_cli(cli, ["--json"] + args, env_overrides=env_overrides) + try: + return json.loads(output or "{}") + except Exception as exc: + raise cmuxError(f"Invalid JSON output: {output!r} ({exc})") + + +def test_list_panels_workspace_relative(c: cmux, cli: str) -> None: + """list-panels with --workspace targets the specified workspace.""" + # Get current workspaces + ws_result = c._call("workspace.list") + workspaces = ws_result.get("workspaces", []) + _must(len(workspaces) >= 1, "Need at least 1 workspace") + + ws_a = workspaces[0] + ws_a_ref = ws_a.get("ref", ws_a["id"]) + + # Use CLI with explicit --workspace flag + payload = _run_cli_json(cli, ["list-panels", "--workspace", ws_a_ref]) + surfaces = payload.get("surfaces", []) + _must(isinstance(surfaces, list), f"Expected surfaces array, got: {payload}") + + # Also test via env var + payload_env = _run_cli_json( + cli, ["list-panels"], + env_overrides={"CMUX_WORKSPACE_ID": ws_a["id"]} + ) + surfaces_env = payload_env.get("surfaces", []) + _must(isinstance(surfaces_env, list), f"Expected surfaces array from env, got: {payload_env}") + + # Both should return surfaces for the same workspace + ws_id_flag = payload.get("workspace_id") or payload.get("workspace_ref") + ws_id_env = payload_env.get("workspace_id") or payload_env.get("workspace_ref") + _must(ws_id_flag is not None, f"Missing workspace ID in flag response: {payload}") + _must(ws_id_env is not None, f"Missing workspace ID in env response: {payload_env}") + + print(" PASS: list-panels workspace-relative (flag and env)") + + +def test_list_panes_workspace_relative(c: cmux, cli: str) -> None: + """list-panes with --workspace targets the specified workspace.""" + ws_result = c._call("workspace.list") + workspaces = ws_result.get("workspaces", []) + _must(len(workspaces) >= 1, "Need at least 1 workspace") + + ws_ref = workspaces[0].get("ref", workspaces[0]["id"]) + + payload = _run_cli_json(cli, ["list-panes", "--workspace", ws_ref]) + panes = payload.get("panes", []) + _must(isinstance(panes, list), f"Expected panes array, got: {payload}") + _must(len(panes) >= 1, f"Expected at least 1 pane, got: {panes}") + + print(" PASS: list-panes workspace-relative") + + +def test_send_workspace_relative(c: cmux, cli: str) -> None: + """send with CMUX_WORKSPACE_ID env var targets that workspace's surface.""" + ws_result = c._call("workspace.list") + workspaces = ws_result.get("workspaces", []) + _must(len(workspaces) >= 1, "Need at least 1 workspace") + + ws = workspaces[0] + + # Get a surface in this workspace + surfaces = c._call("surface.list", {"workspace_id": ws["id"]}) + surface_list = surfaces.get("surfaces", []) + _must(len(surface_list) >= 1, "Need at least 1 surface in workspace") + + # Send a harmless empty echo via env var to verify workspace routing + output = _run_cli( + cli, ["send", " "], + env_overrides={"CMUX_WORKSPACE_ID": ws["id"]} + ) + _must("OK" in output or "surface" in output.lower(), + f"Expected OK from send, got: {output}") + print(" PASS: send workspace-relative (env var accepted)") + + +def test_send_with_explicit_workspace(c: cmux, cli: str) -> None: + """send with --workspace flag targets the specified workspace's surface.""" + ws_result = c._call("workspace.list") + workspaces = ws_result.get("workspaces", []) + _must(len(workspaces) >= 1, "Need at least 1 workspace") + + ws_ref = workspaces[0].get("ref", workspaces[0]["id"]) + + # Send a space character (harmless) with explicit workspace + output = _run_cli(cli, ["send", "--workspace", ws_ref, " "]) + _must(output.startswith("OK") or "surface" in output.lower(), + f"Expected OK from send, got: {output}") + + print(" PASS: send with explicit --workspace") + + +def test_v2_migrated_commands_output_refs(c: cmux, cli: str) -> None: + """Verify migrated commands output refs in JSON by default.""" + # list-panels should output refs + payload = _run_cli_json(cli, ["list-panels"]) + surfaces = payload.get("surfaces", []) + if surfaces: + first = surfaces[0] + _must("ref" in first or "id" in first, + f"Expected ref or id in surface: {first}") + # Default should suppress _id when _ref exists + if "ref" in first: + _must("id" not in first, + f"Default format should suppress id when ref exists: {first}") + + # list-panes should output refs + payload = _run_cli_json(cli, ["list-panes"]) + panes = payload.get("panes", []) + if panes: + first = panes[0] + _must("ref" in first or "id" in first, + f"Expected ref or id in pane: {first}") + + # list-workspaces should output refs + payload = _run_cli_json(cli, ["list-workspaces"]) + workspaces = payload.get("workspaces", []) + if workspaces: + first = workspaces[0] + _must("ref" in first or "id" in first, + f"Expected ref or id in workspace: {first}") + if "ref" in first: + _must("id" not in first, + f"Default format should suppress id when ref exists: {first}") + + print(" PASS: migrated commands output refs by default") + + +def test_surface_health_workspace_relative(c: cmux, cli: str) -> None: + """surface-health with --workspace targets the specified workspace.""" + ws_result = c._call("workspace.list") + workspaces = ws_result.get("workspaces", []) + _must(len(workspaces) >= 1, "Need at least 1 workspace") + + ws_ref = workspaces[0].get("ref", workspaces[0]["id"]) + + payload = _run_cli_json(cli, ["surface-health", "--workspace", ws_ref]) + surfaces = payload.get("surfaces", []) + _must(isinstance(surfaces, list), f"Expected surfaces array, got: {payload}") + + print(" PASS: surface-health workspace-relative") + + +def test_non_json_output_uses_refs(c: cmux, cli: str) -> None: + """Non-JSON output from migrated commands uses ref format.""" + # list-panels non-JSON + output = _run_cli(cli, ["list-panels"]) + _must("surface:" in output or "No surfaces" in output, + f"Expected ref format in list-panels output, got: {output}") + + # list-panes non-JSON + output = _run_cli(cli, ["list-panes"]) + _must("pane:" in output or "No panes" in output, + f"Expected ref format in list-panes output, got: {output}") + + # list-workspaces non-JSON + output = _run_cli(cli, ["list-workspaces"]) + _must("workspace:" in output or "No workspaces" in output, + f"Expected ref format in list-workspaces output, got: {output}") + + print(" PASS: non-JSON output uses refs") + + +def main() -> int: + cli = _find_cli_binary() + print(f"Using CLI: {cli}") + + c = cmux(SOCKET_PATH) + c.connect() + try: + test_list_panels_workspace_relative(c, cli) + test_list_panes_workspace_relative(c, cli) + test_send_workspace_relative(c, cli) + test_send_with_explicit_workspace(c, cli) + test_v2_migrated_commands_output_refs(c, cli) + test_surface_health_workspace_relative(c, cli) + test_non_json_output_uses_refs(c, cli) + finally: + c.close() + + print("\nPASS: All workspace-relative tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())