diff --git a/CLI/cmux.swift b/CLI/cmux.swift index afc2832f..1d4158a2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -959,6 +959,9 @@ struct CMUXCLI { } } + case "tree": + try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "focus-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else { @@ -3576,6 +3579,32 @@ struct CMUXCLI { cmux new-split right cmux new-split down --workspace workspace:1 """ + case "tree": + return """ + Usage: cmux tree [flags] + + Print the hierarchy of windows, workspaces, panes, and surfaces. + + Flags: + --all Include all windows (default: current window only) + --workspace Show only one workspace + --json Structured JSON output + + Output: + Text mode prints a box-drawing tree with markers: + - ◀ active (true focused window/workspace/pane/surface path) + - ◀ here (caller surface where `cmux tree` was invoked) + - workspace [selected] + - pane [focused] + - surface [selected] + Browser surfaces also include their current URL. + + Example: + cmux tree + cmux tree --all + cmux tree --workspace workspace:2 + cmux --json tree --all + """ case "focus-pane": return """ Usage: cmux focus-pane --pane [flags] @@ -4087,6 +4116,536 @@ struct CMUXCLI { return parts.joined(separator: " ") } + private struct TreeCommandOptions { + let includeAllWindows: Bool + let workspaceHandle: String? + let jsonOutput: Bool + } + + private struct TreePath { + let windowHandle: String? + let workspaceHandle: String? + let paneHandle: String? + let surfaceHandle: String? + } + + private func runTreeCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + let options = try parseTreeCommandOptions(commandArgs) + let payload = try buildTreePayload(options: options, client: client) + if jsonOutput || options.jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let windows = payload["windows"] as? [[String: Any]] ?? [] + print(renderTreeText(windows: windows, idFormat: idFormat)) + } + } + + private func parseTreeCommandOptions(_ args: [String]) throws -> TreeCommandOptions { + let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") + if rem0.contains("--workspace") { + throw CLIError(message: "tree requires --workspace ") + } + + var includeAll = false + var jsonOutput = false + var remaining: [String] = [] + for arg in rem0 { + if arg == "--all" { + includeAll = true + continue + } + if arg == "--json" { + jsonOutput = true + continue + } + remaining.append(arg) + } + + if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace --json") + } + if let extra = remaining.first { + throw CLIError(message: "tree: unexpected argument '\(extra)'") + } + + return TreeCommandOptions(includeAllWindows: includeAll, workspaceHandle: workspaceOpt, jsonOutput: jsonOutput) + } + + private func buildTreePayload( + options: TreeCommandOptions, + client: SocketClient + ) throws -> [String: Any] { + var params: [String: Any] = ["all_windows": options.includeAllWindows] + if let workspaceRaw = options.workspaceHandle { + guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { + throw CLIError(message: "Invalid workspace handle") + } + params["workspace_id"] = workspaceHandle + } + if let caller = treeCallerContextFromEnvironment() { + params["caller"] = caller + } + + do { + let payload = try client.sendV2(method: "system.tree", params: params) + return treePayloadWithMarkers(payload) + } catch let error as CLIError where error.message.hasPrefix("method_not_found:") { + // Back-compat fallback for older servers that don't support system.tree. + return try buildLegacyTreePayload(options: options, params: params, client: client) + } + } + + private func buildLegacyTreePayload( + options: TreeCommandOptions, + params: [String: Any], + client: SocketClient + ) throws -> [String: Any] { + var identifyParams: [String: Any] = [:] + if let caller = params["caller"] as? [String: Any], !caller.isEmpty { + identifyParams["caller"] = caller + } + + let identifyPayload = try client.sendV2(method: "system.identify", params: identifyParams) + let focused = identifyPayload["focused"] as? [String: Any] ?? [:] + let caller = identifyPayload["caller"] as? [String: Any] ?? [:] + let activePath = parseTreePath(payload: focused) + let windows = try buildTreeWindowNodes(options: options, activePath: activePath, client: client) + + return treePayloadWithMarkers([ + "active": focused.isEmpty ? NSNull() : focused, + "caller": caller.isEmpty ? NSNull() : caller, + "windows": windows + ]) + } + + private func buildTreeWindowNodes( + options: TreeCommandOptions, + activePath: TreePath, + client: SocketClient + ) throws -> [[String: Any]] { + let windowsPayload = try client.sendV2(method: "window.list") + let allWindows = windowsPayload["windows"] as? [[String: Any]] ?? [] + + if let workspaceRaw = options.workspaceHandle { + guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { + throw CLIError(message: "Invalid workspace handle") + } + + let workspaceListPayload = try client.sendV2(method: "workspace.list", params: ["workspace_id": workspaceHandle]) + let workspaceWindowHandle = (workspaceListPayload["window_ref"] as? String) ?? (workspaceListPayload["window_id"] as? String) + let window = allWindows.first(where: { treeItemMatchesHandle($0, handle: workspaceWindowHandle) }) + ?? treeFallbackWindow(from: workspaceListPayload) + + let workspaces = workspaceListPayload["workspaces"] as? [[String: Any]] ?? [] + if workspaces.isEmpty { + throw CLIError(message: "Workspace not found") + } + let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } + var node = window + let isActiveWindow = treeItemMatchesHandle(node, handle: activePath.windowHandle) + node["current"] = isActiveWindow + node["active"] = isActiveWindow + node["workspaces"] = workspaceNodes + node["workspace_count"] = workspaceNodes.count + return [node] + } + + let targetWindows: [[String: Any]] + if options.includeAllWindows { + targetWindows = allWindows + } else if let currentWindowHandle = activePath.windowHandle { + let currentOnly = allWindows.filter { treeItemMatchesHandle($0, handle: currentWindowHandle) } + targetWindows = currentOnly.isEmpty ? Array(allWindows.prefix(1)) : currentOnly + } else { + targetWindows = Array(allWindows.prefix(1)) + } + + return try targetWindows.map { + try buildTreeWindowNode( + window: $0, + activePath: activePath, + client: client + ) + } + } + + private func treeFallbackWindow(from payload: [String: Any]) -> [String: Any] { + let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] + let selectedWorkspace = workspaces.first(where: { ($0["selected"] as? Bool) == true }) + return [ + "id": payload["window_id"] ?? NSNull(), + "ref": payload["window_ref"] ?? NSNull(), + "index": 0, + "key": false, + "visible": true, + "workspace_count": workspaces.count, + "selected_workspace_id": selectedWorkspace?["id"] ?? NSNull(), + "selected_workspace_ref": selectedWorkspace?["ref"] ?? NSNull(), + ] + } + + private func buildTreeWindowNode( + window: [String: Any], + activePath: TreePath, + client: SocketClient + ) throws -> [String: Any] { + var workspaceParams: [String: Any] = [:] + if let windowHandle = treeItemHandle(window) { + workspaceParams["window_id"] = windowHandle + } + let workspacePayload = try client.sendV2(method: "workspace.list", params: workspaceParams) + let workspaces = workspacePayload["workspaces"] as? [[String: Any]] ?? [] + let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } + var windowNode = window + let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) + windowNode["current"] = isActiveWindow + windowNode["active"] = isActiveWindow + windowNode["workspaces"] = workspaceNodes + windowNode["workspace_count"] = workspaceNodes.count + return windowNode + } + + private func buildTreeWorkspaceNode( + workspace: [String: Any], + activePath: TreePath, + client: SocketClient + ) throws -> [String: Any] { + var workspaceNode = workspace + guard let workspaceHandle = treeItemHandle(workspace) else { + workspaceNode["panes"] = [] + return workspaceNode + } + + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceHandle]) + let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceHandle]) + let panes = panePayload["panes"] as? [[String: Any]] ?? [] + let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] + let browserURLsByHandle = fetchTreeBrowserURLs( + workspaceHandle: workspaceHandle, + surfaces: surfaces, + client: client + ) + + var surfacesByPane: [String: [[String: Any]]] = [:] + for surface in surfaces { + var surfaceNode = surface + if surfaceNode["selected"] == nil { + surfaceNode["selected"] = (surfaceNode["selected_in_pane"] as? Bool) == true + } + surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) + + let surfaceType = ((surfaceNode["type"] as? String) ?? "").lowercased() + if surfaceType == "browser", + let url = treeBrowserURL(surface: surfaceNode, urlsByHandle: browserURLsByHandle), + !url.isEmpty { + surfaceNode["url"] = url + } else { + surfaceNode["url"] = NSNull() + } + + guard let paneHandle = treeRelatedHandle(surfaceNode, refKey: "pane_ref", idKey: "pane_id") else { + continue + } + surfacesByPane[paneHandle, default: []].append(surfaceNode) + } + + for paneHandle in surfacesByPane.keys { + surfacesByPane[paneHandle]?.sort { + let lhs = intFromAny($0["index_in_pane"]) ?? intFromAny($0["index"]) ?? Int.max + let rhs = intFromAny($1["index_in_pane"]) ?? intFromAny($1["index"]) ?? Int.max + return lhs < rhs + } + } + + let paneNodes: [[String: Any]] = panes.map { pane in + var paneNode = pane + paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) + if let paneHandle = treeItemHandle(paneNode) { + paneNode["surfaces"] = surfacesByPane[paneHandle] ?? [] + } else { + paneNode["surfaces"] = [] + } + return paneNode + } + + workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) + workspaceNode["panes"] = paneNodes + return workspaceNode + } + + private func treeItemHandle(_ item: [String: Any]) -> String? { + if let ref = item["ref"] as? String, !ref.isEmpty { + return ref + } + if let id = item["id"] as? String, !id.isEmpty { + return id + } + return nil + } + + private func treeRelatedHandle(_ item: [String: Any], refKey: String, idKey: String) -> String? { + if let ref = item[refKey] as? String, !ref.isEmpty { + return ref + } + if let id = item[idKey] as? String, !id.isEmpty { + return id + } + return nil + } + + private func parseTreePath(payload: [String: Any]) -> TreePath { + return TreePath( + windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"), + workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"), + paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"), + surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id") + ) + } + + private func treeCallerContextFromEnvironment() -> [String: Any]? { + let env = ProcessInfo.processInfo.environment + let workspaceRaw = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let surfaceRaw = env["CMUX_SURFACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + var caller: [String: Any] = [:] + if let workspaceRaw, !workspaceRaw.isEmpty { + caller["workspace_id"] = workspaceRaw + } + if let surfaceRaw, !surfaceRaw.isEmpty { + caller["surface_id"] = surfaceRaw + } + return caller.isEmpty ? nil : caller + } + + private func treePayloadWithMarkers(_ payload: [String: Any]) -> [String: Any] { + let active = payload["active"] as? [String: Any] ?? [:] + let caller = payload["caller"] as? [String: Any] ?? [:] + let activePath = parseTreePath(payload: active) + let callerPath = parseTreePath(payload: caller) + var result = payload + let windows = payload["windows"] as? [[String: Any]] ?? [] + result["windows"] = treeApplyMarkers(windows: windows, activePath: activePath, callerPath: callerPath) + if result["active"] == nil { + result["active"] = active.isEmpty ? NSNull() : active + } + if result["caller"] == nil { + result["caller"] = caller.isEmpty ? NSNull() : caller + } + return result + } + + private func treeApplyMarkers( + windows: [[String: Any]], + activePath: TreePath, + callerPath: TreePath + ) -> [[String: Any]] { + return windows.map { window in + var windowNode = window + let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) + windowNode["current"] = isActiveWindow + windowNode["active"] = isActiveWindow + + let workspaces = window["workspaces"] as? [[String: Any]] ?? [] + let workspaceNodes = workspaces.map { workspace in + var workspaceNode = workspace + workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) + + let panes = workspace["panes"] as? [[String: Any]] ?? [] + let paneNodes = panes.map { pane in + var paneNode = pane + paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + paneNode["surfaces"] = surfaces.map { surface in + var surfaceNode = surface + surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) + surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle) + return surfaceNode + } + return paneNode + } + + workspaceNode["panes"] = paneNodes + return workspaceNode + } + + windowNode["workspaces"] = workspaceNodes + return windowNode + } + } + + private func fetchTreeBrowserURLs( + workspaceHandle: String, + surfaces: [[String: Any]], + client: SocketClient + ) -> [String: String] { + let hasBrowserSurfaces = surfaces.contains { + (($0["type"] as? String) ?? "").lowercased() == "browser" + } + guard hasBrowserSurfaces else { return [:] } + + if let payload = try? client.sendV2( + method: "browser.tab.list", + params: ["workspace_id": workspaceHandle] + ) { + let tabs = payload["tabs"] as? [[String: Any]] ?? [] + var urlByHandle: [String: String] = [:] + for tab in tabs { + guard let url = tab["url"] as? String, !url.isEmpty else { continue } + if let id = tab["id"] as? String, !id.isEmpty { + urlByHandle[id] = url + } + if let ref = tab["ref"] as? String, !ref.isEmpty { + urlByHandle[ref] = url + } + } + return urlByHandle + } + + // Fallback for older servers that may not support browser.tab.list. + var fallbackURLs: [String: String] = [:] + for surface in surfaces { + guard ((surface["type"] as? String) ?? "").lowercased() == "browser" else { continue } + guard let surfaceHandle = treeItemHandle(surface) else { continue } + guard let payload = try? client.sendV2( + method: "browser.url.get", + params: ["workspace_id": workspaceHandle, "surface_id": surfaceHandle] + ), + let url = payload["url"] as? String, + !url.isEmpty else { + continue + } + fallbackURLs[surfaceHandle] = url + if let id = surface["id"] as? String, !id.isEmpty { + fallbackURLs[id] = url + } + if let ref = surface["ref"] as? String, !ref.isEmpty { + fallbackURLs[ref] = url + } + } + return fallbackURLs + } + + private func treeBrowserURL(surface: [String: Any], urlsByHandle: [String: String]) -> String? { + if let id = surface["id"] as? String, let url = urlsByHandle[id] { + return url + } + if let ref = surface["ref"] as? String, let url = urlsByHandle[ref] { + return url + } + if let handle = treeItemHandle(surface), let url = urlsByHandle[handle] { + return url + } + return nil + } + + private func treeItemMatchesHandle(_ item: [String: Any], handle: String?) -> Bool { + guard let handle = handle?.trimmingCharacters(in: .whitespacesAndNewlines), !handle.isEmpty else { + return false + } + return (item["id"] as? String) == handle || (item["ref"] as? String) == handle + } + + private func renderTreeText(windows: [[String: Any]], idFormat: CLIIDFormat) -> String { + guard !windows.isEmpty else { return "No windows" } + + var lines: [String] = [] + for window in windows { + lines.append(treeWindowLabel(window, idFormat: idFormat)) + + let workspaces = window["workspaces"] as? [[String: Any]] ?? [] + for (workspaceIndex, workspace) in workspaces.enumerated() { + let workspaceIsLast = workspaceIndex == workspaces.count - 1 + let workspaceBranch = workspaceIsLast ? "└── " : "├── " + let workspaceIndent = workspaceIsLast ? " " : "│ " + lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))") + + let panes = workspace["panes"] as? [[String: Any]] ?? [] + for (paneIndex, pane) in panes.enumerated() { + let paneIsLast = paneIndex == panes.count - 1 + let paneBranch = paneIsLast ? "└── " : "├── " + let paneIndent = paneIsLast ? " " : "│ " + lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))") + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + for (surfaceIndex, surface) in surfaces.enumerated() { + let surfaceIsLast = surfaceIndex == surfaces.count - 1 + let surfaceBranch = surfaceIsLast ? "└── " : "├── " + lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))") + } + } + } + } + + return lines.joined(separator: "\n") + } + + private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["window \(textHandle(window, idFormat: idFormat))"] + if (window["current"] as? Bool) == true { + parts.append("[current]") + } + if (window["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"] + let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + parts.append("\"\(title)\"") + } + if (workspace["selected"] as? Bool) == true { + parts.append("[selected]") + } + if (workspace["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String { + var parts = ["pane \(textHandle(pane, idFormat: idFormat))"] + if (pane["focused"] as? Bool) == true { + parts.append("[focused]") + } + if (pane["active"] as? Bool) == true { + parts.append("◀ active") + } + return parts.joined(separator: " ") + } + + private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String { + let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let surfaceType = rawType.isEmpty ? "unknown" : rawType + var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] + let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + parts.append("\"\(title)\"") + } + if (surface["selected"] as? Bool) == true { + parts.append("[selected]") + } + if (surface["active"] as? Bool) == true { + parts.append("◀ active") + } + if (surface["here"] as? Bool) == true { + parts.append("◀ here") + } + if surfaceType.lowercased() == "browser", + let url = surface["url"] as? String, + !url.isEmpty { + parts.append(url) + } + return parts.joined(separator: " ") + } + private func isUUID(_ value: String) -> Bool { return UUID(uuidString: value) != nil } @@ -5277,6 +5836,7 @@ struct CMUXCLI { new-split [--workspace ] [--surface ] [--panel ] list-panes [--workspace ] list-pane-surfaces [--workspace ] [--pane ] + tree [--all] [--workspace ] focus-pane --pane [--workspace ] new-pane [--type ] [--direction ] [--workspace ] [--url ] new-surface [--type ] [--pane ] [--workspace ] [--url ] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index fbdaac2e..57118e23 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1032,6 +1032,8 @@ class TerminalController { case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) + case "system.tree": + return v2Result(id: id, self.v2SystemTree(params: params)) case "auth.login": return v2Ok( id: id, @@ -1412,6 +1414,7 @@ class TerminalController { "system.ping", "system.capabilities", "system.identify", + "system.tree", "auth.login", "window.list", "window.current", @@ -1678,6 +1681,203 @@ class TerminalController { ] } + private func v2SystemTree(params: [String: Any]) -> V2CallResult { + let workspaceFilter = v2UUID(params, "workspace_id") + if params["workspace_id"] != nil && workspaceFilter == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let includeAllWindows = v2Bool(params, "all_windows") ?? false + + var identifyParams: [String: Any] = [:] + if let caller = params["caller"] as? [String: Any], !caller.isEmpty { + identifyParams["caller"] = caller + } + let identifyPayload = v2Identify(params: identifyParams) + let focused = identifyPayload["focused"] as? [String: Any] ?? [:] + let caller = identifyPayload["caller"] as? [String: Any] ?? [:] + let focusedWindowId = v2UUIDAny(focused["window_id"]) ?? v2UUIDAny(focused["window_ref"]) + + var windowNodes: [[String: Any]] = [] + var workspaceFound = (workspaceFilter == nil) + + v2MainSync { + guard let app = AppDelegate.shared else { return } + let summaries = app.listMainWindowSummaries() + let defaultWindowId = focusedWindowId ?? summaries.first?.windowId + + for (windowIndex, summary) in summaries.enumerated() { + guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue } + + if let workspaceFilter { + guard let workspaceIndex = manager.tabs.firstIndex(where: { $0.id == workspaceFilter }) else { + continue + } + let workspace = manager.tabs[workspaceIndex] + let workspaceNode = v2TreeWorkspaceNode( + workspace: workspace, + index: workspaceIndex, + selected: workspace.id == manager.selectedTabId + ) + windowNodes = [ + v2TreeWindowNode( + summary: summary, + index: windowIndex, + workspaceNodes: [workspaceNode] + ) + ] + workspaceFound = true + break + } + + if !includeAllWindows && summary.windowId != defaultWindowId { + continue + } + + let workspaceNodesForWindow = manager.tabs.enumerated().map { workspaceIndex, workspace in + v2TreeWorkspaceNode( + workspace: workspace, + index: workspaceIndex, + selected: workspace.id == manager.selectedTabId + ) + } + + windowNodes.append( + v2TreeWindowNode( + summary: summary, + index: windowIndex, + workspaceNodes: workspaceNodesForWindow + ) + ) + } + } + + if let workspaceFilter, !workspaceFound { + return .err( + code: "not_found", + message: "Workspace not found", + data: [ + "workspace_id": workspaceFilter.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter) + ] + ) + } + + return .ok([ + "active": focused.isEmpty ? (NSNull() as Any) : focused, + "caller": caller.isEmpty ? (NSNull() as Any) : caller, + "windows": windowNodes + ]) + } + + private func v2TreeWindowNode( + summary: AppDelegate.MainWindowSummary, + index: Int, + workspaceNodes: [[String: Any]] + ) -> [String: Any] { + return [ + "id": summary.windowId.uuidString, + "ref": v2Ref(kind: .window, uuid: summary.windowId), + "index": index, + "key": summary.isKeyWindow, + "visible": summary.isVisible, + "workspace_count": workspaceNodes.count, + "selected_workspace_id": v2OrNull(summary.selectedWorkspaceId?.uuidString), + "selected_workspace_ref": v2Ref(kind: .workspace, uuid: summary.selectedWorkspaceId), + "workspaces": workspaceNodes + ] + } + + private func v2TreeWorkspaceNode( + workspace: Workspace, + index: Int, + selected: Bool + ) -> [String: Any] { + var paneByPanelId: [UUID: UUID] = [:] + var indexInPaneByPanelId: [UUID: Int] = [:] + var selectedInPaneByPanelId: [UUID: Bool] = [:] + + let paneIds = workspace.bonsplitController.allPaneIds + for paneId in paneIds { + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + for (tabIndex, tab) in tabs.enumerated() { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + paneByPanelId[panelId] = paneId.id + indexInPaneByPanelId[panelId] = tabIndex + selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + } + } + + var surfacesByPane: [UUID: [[String: Any]]] = [:] + let focusedSurfaceId = workspace.focusedPanelId + for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() { + let paneUUID = paneByPanelId[panel.id] + let selectedInPane = selectedInPaneByPanelId[panel.id] ?? false + + var item: [String: Any] = [ + "id": panel.id.uuidString, + "ref": v2Ref(kind: .surface, uuid: panel.id), + "index": surfaceIndex, + "type": panel.panelType.rawValue, + "title": workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "focused": panel.id == focusedSurfaceId, + "selected": selectedInPane, + "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]), + "pane_id": v2OrNull(paneUUID?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), + "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]) + ] + + if panel.panelType == .browser, let browserPanel = panel as? BrowserPanel { + item["url"] = browserPanel.currentURL?.absoluteString ?? "" + } else { + item["url"] = NSNull() + } + if let paneUUID { + surfacesByPane[paneUUID, default: []].append(item) + } + } + + for paneUUID in surfacesByPane.keys { + surfacesByPane[paneUUID]?.sort { + let lhs = ($0["index_in_pane"] as? Int) ?? ($0["index"] as? Int) ?? Int.max + let rhs = ($1["index_in_pane"] as? Int) ?? ($1["index"] as? Int) ?? Int.max + return lhs < rhs + } + } + + let focusedPaneId = workspace.bonsplitController.focusedPaneId + let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) } + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + let selectedSurfaceUUID = selectedTab.flatMap { workspace.panelIdFromSurfaceId($0.id) } + + return [ + "id": paneId.id.uuidString, + "ref": v2Ref(kind: .pane, uuid: paneId.id), + "index": paneIndex, + "focused": paneId == focusedPaneId, + "surface_ids": surfaceUUIDs.map { $0.uuidString }, + "surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) }, + "selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString), + "selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID), + "surface_count": surfaceUUIDs.count, + "surfaces": surfacesByPane[paneId.id] ?? [] + ] + } + + return [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "index": index, + "title": workspace.title, + "selected": selected, + "pinned": workspace.isPinned, + "panes": panes + ] + } + // MARK: - V2 Helpers (encoding + result plumbing) // MARK: - V2 Helpers (encoding + result plumbing) diff --git a/tests/test_cli_tree_command.py b/tests/test_cli_tree_command.py new file mode 100644 index 00000000..f19484c5 --- /dev/null +++ b/tests/test_cli_tree_command.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Regression test: `cmux tree` command wiring and output contract.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + controller_path = repo_root / "Sources" / "TerminalController.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + if not controller_path.exists(): + print(f"FAIL: missing expected file: {controller_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + controller_content = controller_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + 'case "tree":\n try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)', + "Missing `tree` command dispatch", + failures, + ) + require( + content, + "tree [--all] [--workspace ]", + "Top-level usage text missing tree command", + failures, + ) + require( + content, + "Usage: cmux tree [flags]", + "Subcommand help for `cmux tree --help` is missing", + failures, + ) + require( + content, + "Known flags: --all --workspace --json", + "Tree flag validation for --all/--workspace is missing", + failures, + ) + require( + content, + "--json Structured JSON output", + "Tree help text should document --json", + failures, + ) + require( + content, + 'print(jsonString(formatIDs(payload, mode: idFormat)))', + "Tree command JSON output should honor --id-format conversion", + failures, + ) + + # Data sources needed for full hierarchy + browser URLs. + for method in [ + 'method: "system.tree"', + 'method: "system.identify"', + 'method: "window.list"', + 'method: "workspace.list"', + 'method: "pane.list"', + 'method: "surface.list"', + 'method: "browser.tab.list"', + 'method: "browser.url.get"', + ]: + require( + content, + method, + f"Tree command is missing expected API call: {method}", + failures, + ) + + # Text tree rendering contract. + for glyph in ['"├── "', '"└── "', '"│ "']: + require( + content, + glyph, + f"Tree output missing box-drawing glyph: {glyph}", + failures, + ) + + for marker in ["[current]", "[selected]", "[focused]", "◀ active", "◀ here"]: + require( + content, + marker, + f"Tree output missing required marker: {marker}", + failures, + ) + + require( + content, + 'surfaceType.lowercased() == "browser"', + "Tree surface rendering should special-case browser surfaces", + failures, + ) + require( + content, + 'let url = surface["url"] as? String', + "Tree surface rendering should include browser URL when available", + failures, + ) + + # Server-side one-shot hierarchy path for performance. + for needle, message in [ + ('case "system.tree":', "Socket router is missing system.tree dispatch"), + ('"system.tree"', "Capabilities list should advertise system.tree"), + ("private func v2SystemTree(params: [String: Any]) -> V2CallResult {", "Missing v2SystemTree implementation"), + ('"active":', "system.tree payload should include focused path"), + ('"caller":', "system.tree payload should include caller path"), + ('"windows":', "system.tree payload should include hierarchy windows"), + ]: + require(controller_content, needle, message, failures) + + if failures: + print("FAIL: cmux tree command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: cmux tree command wiring and output contract are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())