diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a6d1ae32..b0740d9f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -860,6 +860,19 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.select", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "rename-workspace", "rename-window": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) + let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "\(command) requires a title") + } + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let params: [String: Any] = ["title": title, "workspace_id": wsId] + let payload = try client.sendV2(method: "workspace.rename", 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") if jsonOutput { @@ -1025,6 +1038,38 @@ struct CMUXCLI { let response = try client.send(command: "simulate_app_active") print(response) + case "capture-pane", + "resize-pane", + "pipe-pane", + "wait-for", + "swap-pane", + "break-pane", + "join-pane", + "last-window", + "last-pane", + "next-window", + "previous-window", + "find-window", + "clear-history", + "set-hook", + "popup", + "bind-key", + "unbind-key", + "copy-mode", + "set-buffer", + "paste-buffer", + "list-buffers", + "respawn-pane", + "display-message": + try runTmuxCompatCommand( + command: command, + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "help": print(usage()) @@ -2840,6 +2885,54 @@ struct CMUXCLI { cmux select-workspace --workspace workspace:2 cmux select-workspace --workspace 0 """ + case "rename-workspace", "rename-window": + return """ + Usage: cmux rename-workspace [--workspace ] [--] + + Rename a workspace. Defaults to the current workspace. + tmux-compatible alias: rename-window + + Flags: + --workspace <id|ref> Workspace to rename (default: current workspace) + + Example: + cmux rename-workspace "backend logs" + cmux rename-window --workspace workspace:2 "agent run" + """ + case "capture-pane": + return """ + Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + + tmux-compatible alias for reading terminal text from a pane. + + Example: + cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 + """ + case "resize-pane": + return """ + Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + + tmux-compatible pane resize command. + Note: currently returns not_supported until programmable divider resize is implemented. + """ + case "pipe-pane": + return """ + Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + + Capture pane text and pipe it to a shell command via stdin. + """ + case "wait-for": + return """ + Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>] + + Wait for or signal a named synchronization token. + """ + case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message": + return """ + Usage: cmux \(command) --help + + tmux compatibility command. See `cmux --help` for exact syntax. + """ case "read-screen": return """ Usage: cmux read-screen [flags] @@ -3078,6 +3171,438 @@ struct CMUXCLI { return output } + private struct TmuxCompatStore: Codable { + var buffers: [String: String] = [:] + var hooks: [String: String] = [:] + } + + private func tmuxCompatStoreURL() -> URL { + let root = NSString(string: "~/.cmuxterm").expandingTildeInPath + return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json") + } + + private func loadTmuxCompatStore() -> TmuxCompatStore { + let url = tmuxCompatStoreURL() + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else { + return TmuxCompatStore() + } + return decoded + } + + private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws { + let url = tmuxCompatStoreURL() + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil) + let data = try JSONEncoder().encode(store) + try data.write(to: url, options: .atomic) + } + + private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + process.waitUntilExit() + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, stdout, stderr) + } + + private func tmuxWaitForSignalURL(name: String) -> URL { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig") + } + + private func runTmuxCompatCommand( + command: String, + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + switch command { + case "capture-pane": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let (linesArg, rem2) = parseOption(rem1, name: "--lines") + let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == 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 includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try client.sendV2(method: "surface.read_text", params: params) + if jsonOutput { + print(jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + + case "resize-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let amountArg = optionValue(commandArgs, name: "--amount") + let amount = Int(amountArg ?? "1") ?? 1 + if amount <= 0 { + throw CLIError(message: "--amount must be greater than 0") + } + + let direction: String = { + if commandArgs.contains("-L") { return "left" } + if commandArgs.contains("-R") { return "right" } + if commandArgs.contains("-U") { return "up" } + if commandArgs.contains("-D") { return "down" } + return "right" + }() + + var params: [String: Any] = ["direction": direction, "amount": amount] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let paneId { params["pane_id"] = paneId } + let payload = try client.sendV2(method: "pane.resize", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "pipe-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText: String = { + if let cmdOpt { return cmdOpt } + let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed + }() + guard !commandText.isEmpty else { + throw CLIError(message: "pipe-pane requires --command <shell-command>") + } + + var params: [String: Any] = ["scrollback": true] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.read_text", params: params) + let text = (payload["text"] as? String) ?? "" + let shell = try runShellCommand(commandText, stdinText: text) + if shell.status != 0 { + throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)") + } + if jsonOutput { + print(jsonString([ + "ok": true, + "status": shell.status, + "stdout": shell.stdout, + "stderr": shell.stderr + ])) + } else { + if !shell.stdout.isEmpty { + print(shell.stdout, terminator: "") + } + print("OK") + } + + case "wait-for": + let signal = commandArgs.contains("-S") || commandArgs.contains("--signal") + let timeoutRaw = optionValue(commandArgs, name: "--timeout") + let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0 + let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? "" + guard !name.isEmpty else { + throw CLIError(message: "wait-for requires a name") + } + let signalURL = tmuxWaitForSignalURL(name: name) + if signal { + FileManager.default.createFile(atPath: signalURL.path, contents: Data()) + print("OK") + return + } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: signalURL.path) { + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } + Thread.sleep(forTimeInterval: 0.05) + } + throw CLIError(message: "wait-for timed out waiting for '\(name)'") + + case "swap-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else { + throw CLIError(message: "swap-pane requires --pane") + } + guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "swap-pane requires --target-pane") + } + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId) + let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId) + if let sourcePane { params["pane_id"] = sourcePane } + if let targetPane { params["target_pane_id"] = targetPane } + let payload = try client.sendV2(method: "pane.swap", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "break-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.break", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "join-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let sourcePaneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "join-pane requires --target-pane") + } + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId) + if let sourcePaneId { params["pane_id"] = sourcePaneId } + let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId) + if let targetPaneId { params["target_pane_id"] = targetPaneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.join", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "last-window": + let payload = try client.sendV2(method: "workspace.last") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "next-window": + let payload = try client.sendV2(method: "workspace.next") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "previous-window": + let payload = try client.sendV2(method: "workspace.previous") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "last-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + 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.last", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "find-window": + let includeContent = commandArgs.contains("--content") + let shouldSelect = commandArgs.contains("--select") + let query = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let listPayload = try client.sendV2(method: "workspace.list") + let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? [] + + var matches: [[String: Any]] = [] + for ws in workspaces { + let title = (ws["title"] as? String) ?? "" + let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query) + var contentMatch = false + if includeContent && !query.isEmpty, let wsId = ws["id"] as? String { + let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId]) + let text = (textPayload?["text"] as? String) ?? "" + contentMatch = text.localizedCaseInsensitiveContains(query) + } + if titleMatch || contentMatch { + matches.append(ws) + } + } + + if shouldSelect, let first = matches.first, let wsId = first["id"] as? String { + _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId]) + } + + if jsonOutput { + let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any] + print(jsonString(["matches": formatted?["matches"] ?? []])) + } else if matches.isEmpty { + print("No matches") + } else { + for item in matches { + let handle = textHandle(item, idFormat: idFormat) + let title = (item["title"] as? String) ?? "" + print("\(handle) \"\(title)\"") + } + } + + case "clear-history": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + 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, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.clear_history", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) + + case "set-hook": + var store = loadTmuxCompatStore() + if commandArgs.contains("--list") { + if jsonOutput { + print(jsonString(["hooks": store.hooks])) + } else if store.hooks.isEmpty { + print("No hooks configured") + } else { + for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) { + print("\(event) -> \(hookCmd)") + } + } + return + } + if commandArgs.contains("--unset") { + guard let event = commandArgs.last else { + throw CLIError(message: "set-hook --unset requires an event name") + } + store.hooks.removeValue(forKey: event) + try saveTmuxCompatStore(store) + print("OK") + return + } + guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else { + throw CLIError(message: "set-hook requires <event> <command>") + } + let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !commandText.isEmpty else { + throw CLIError(message: "set-hook requires <event> <command>") + } + store.hooks[event] = commandText + try saveTmuxCompatStore(store) + print("OK") + + case "popup": + throw CLIError(message: "popup is not supported yet in cmux CLI parity mode") + + case "bind-key", "unbind-key", "copy-mode": + throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode") + + case "set-buffer": + let (nameArg, rem0) = parseOption(commandArgs, name: "--name") + let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default" + let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { + throw CLIError(message: "set-buffer requires text") + } + var store = loadTmuxCompatStore() + store.buffers[name] = content + try saveTmuxCompatStore(store) + print("OK") + + case "list-buffers": + let store = loadTmuxCompatStore() + if jsonOutput { + let payload = store.buffers.map { key, value in ["name": key, "size": value.count] } + print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }])) + } else if store.buffers.isEmpty { + print("No buffers") + } else { + for key in store.buffers.keys.sorted() { + let size = store.buffers[key]?.count ?? 0 + print("\(key)\t\(size)") + } + } + + case "paste-buffer": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let name = optionValue(commandArgs, name: "--name") ?? "default" + let store = loadTmuxCompatStore() + guard let buffer = store.buffers[name] else { + throw CLIError(message: "Buffer not found: \(name)") + } + var params: [String: Any] = ["text": buffer] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + 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: "OK") + + case "respawn-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) + let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText + var params: [String: Any] = ["text": finalCommand + "\n"] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + 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: "OK") + + case "display-message": + let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print") + let message = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { + throw CLIError(message: "display-message requires text") + } + if printOnly { + print(message) + return + } + let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message]) + if jsonOutput { + print(jsonString(payload)) + } else { + print(message) + } + + default: + throw CLIError(message: "Unsupported tmux compatibility command: \(command)") + } + } + private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) @@ -3515,6 +4040,8 @@ struct CMUXCLI { focus-panel --panel <id|ref> [--workspace <id|ref>] close-workspace --workspace <id|ref> select-workspace --workspace <id|ref> + rename-workspace [--workspace <id|ref>] <title> + rename-window [--workspace <id|ref>] <title> current-workspace read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> @@ -3528,6 +4055,27 @@ struct CMUXCLI { set-app-focus <active|inactive|clear> simulate-app-active + # tmux compatibility commands + capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + wait-for [-S|--signal] <name> [--timeout <seconds>] + swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] + break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + next-window | previous-window | last-window + last-pane [--workspace <id|ref>] + find-window [--content] [--select] <query> + clear-history [--workspace <id|ref>] [--surface <id|ref>] + set-hook [--list] [--unset <event>] | <event> <command> + popup + bind-key | unbind-key | copy-mode + set-buffer [--name <name>] <text> + list-buffers + paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] + respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] + display-message [-p|--print] <text> + browser [--surface <id|ref|index> | <surface>] <subcommand> ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0393b02e..7e4d4480 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -667,6 +667,14 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params)) case "workspace.reorder": return v2Result(id: id, self.v2WorkspaceReorder(params: params)) + case "workspace.rename": + return v2Result(id: id, self.v2WorkspaceRename(params: params)) + case "workspace.next": + return v2Result(id: id, self.v2WorkspaceNext(params: params)) + case "workspace.previous": + return v2Result(id: id, self.v2WorkspacePrevious(params: params)) + case "workspace.last": + return v2Result(id: id, self.v2WorkspaceLast(params: params)) // Surfaces / input @@ -696,6 +704,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": return v2Result(id: id, self.v2SurfaceSendKey(params: params)) + case "surface.clear_history": + return v2Result(id: id, self.v2SurfaceClearHistory(params: params)) case "surface.trigger_flash": return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params)) @@ -708,6 +718,16 @@ class TerminalController { return v2Result(id: id, self.v2PaneSurfaces(params: params)) case "pane.create": return v2Result(id: id, self.v2PaneCreate(params: params)) + case "pane.resize": + return v2Result(id: id, self.v2PaneResize(params: params)) + case "pane.swap": + return v2Result(id: id, self.v2PaneSwap(params: params)) + case "pane.break": + return v2Result(id: id, self.v2PaneBreak(params: params)) + case "pane.join": + return v2Result(id: id, self.v2PaneJoin(params: params)) + case "pane.last": + return v2Result(id: id, self.v2PaneLast(params: params)) // Notifications case "notification.create": @@ -962,6 +982,10 @@ class TerminalController { "workspace.close", "workspace.move_to_window", "workspace.reorder", + "workspace.rename", + "workspace.next", + "workspace.previous", + "workspace.last", "surface.list", "surface.current", "surface.focus", @@ -976,11 +1000,17 @@ class TerminalController { "surface.send_text", "surface.send_key", "surface.read_text", + "surface.clear_history", "surface.trigger_flash", "pane.list", "pane.focus", "pane.surfaces", "pane.create", + "pane.resize", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", "notification.create", "notification.create_for_surface", "notification.create_for_target", @@ -1686,6 +1716,116 @@ class TerminalController { "index": v2OrNull(newIndex) ]) } + private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + } + + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + var renamed = false + v2MainSync { + guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + tabManager.setCustomTitle(tabId: workspaceId, title: title) + renamed = true + } + + guard renamed else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ]) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "title": title + ]) + } + private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectNextTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectPreviousTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) + v2MainSync { + guard let before = tabManager.selectedTabId else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.navigateBack() + guard let after = tabManager.selectedTabId, after != before else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": after.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: after), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } // MARK: - V2 Surface Methods @@ -2390,6 +2530,47 @@ class TerminalController { return result } + private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + guard terminalPanel.performBindingAction("clear_screen") else { + result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) + return + } + + terminalPanel.surface.forceRefresh() + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + + return result + } + private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -2725,6 +2906,269 @@ class TerminalController { return result } + private func v2PaneResize(params: [String: Any]) -> V2CallResult { + let direction = (v2String(params, "direction") ?? "").lowercased() + let amount = v2Int(params, "amount") ?? 1 + guard ["left", "right", "up", "down"].contains(direction), amount > 0 else { + return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) + } + return .err( + code: "not_supported", + message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API", + data: [ + "direction": direction, + "amount": amount + ] + ) + } + + private func v2PaneSwap(params: [String: Any]) -> V2CallResult { + guard let sourcePaneUUID = v2UUID(params, "pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil) + } + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + if sourcePaneUUID == targetPaneUUID { + return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) + v2MainSync { + guard let located = v2LocatePane(sourcePaneUUID) else { + result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) + return + } + guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else { + result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) + return + } + let workspace = located.workspace + let sourcePane = located.paneId + + guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane), + let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane), + let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id), + let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else { + result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) + return + } + + // Keep pane identities stable during swap when one side has a single surface. + var sourcePlaceholder: UUID? + var targetPlaceholder: UUID? + if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { + sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id + if sourcePlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) + return + } + } + if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { + targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + if targetPlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) + return + } + } + + guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) + return + } + guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) + return + } + + if let sourcePlaceholder { + _ = workspace.closePanel(sourcePlaceholder, force: true) + } + if let targetPlaceholder { + _ = workspace.closePanel(targetPlaceholder, force: true) + } + + if focus { + workspace.bonsplitController.focusPane(targetPane) + } + let windowId = located.windowId + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "pane_id": sourcePane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id), + "target_pane_id": targetPane.id.uuidString, + "target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id), + "source_surface_id": sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId), + "target_surface_id": targetSurfaceId.uuidString, + "target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId) + ]) + } + return result + } + + private func v2PaneBreak(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) + v2MainSync { + guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let sourcePaneUUID = v2UUID(params, "pane_id") + let sourcePane: PaneID? = { + if let sourcePaneUUID { + return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID }) + } + return sourceWorkspace.bonsplitController.focusedPaneId + }() + + let surfaceId: UUID? = { + if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface } + if let sourcePane, + let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) { + return sourceWorkspace.panelIdFromSurfaceId(selected.id) + } + return sourceWorkspace.focusedPanelId + }() + guard let surfaceId else { + result = .err(code: "not_found", message: "No source surface to break", data: nil) + return + } + guard sourceWorkspace.panels[surfaceId] != nil else { + result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + return + } + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId) + let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId) + + guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else { + result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil) + return + } + + let destinationWorkspace = tabManager.addWorkspace() + guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) + return + } + + guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) + return + } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": destinationWorkspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id), + "pane_id": destinationPane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) + ]) + } + return result + } + + private func v2PaneJoin(params: [String: Any]) -> V2CallResult { + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + + var surfaceId = v2UUID(params, "surface_id") + if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { + guard let sourceLocated = v2LocatePane(sourcePaneUUID), + let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), + let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ + "pane_id": sourcePaneUUID.uuidString + ]) + } + surfaceId = selectedSurface + } + guard let surfaceId else { + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) + } + + var moveParams: [String: Any] = [ + "surface_id": surfaceId.uuidString, + "pane_id": targetPaneUUID.uuidString + ] + if let focus = v2Bool(params, "focus") { + moveParams["focus"] = focus + } + return v2SurfaceMove(params: moveParams) + } + + private func v2PaneLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard let focused = ws.bonsplitController.focusedPaneId else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { + result = .err(code: "not_found", message: "No alternate pane available", data: nil) + return + } + + ws.bonsplitController.focusPane(target) + let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": target.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: target.id), + "surface_id": v2OrNull(selectedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId) + ]) + } + return result + } + // MARK: - V2 Notification Methods private func v2NotificationCreate(params: [String: Any]) -> V2CallResult { diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index f4d03d09..cf94aae2 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -398,12 +398,43 @@ class cmux: wsid = self._resolve_workspace_id(workspace) self._call("workspace.select", {"workspace_id": wsid}) + def rename_workspace(self, title: str, workspace: Union[str, int, None] = None) -> None: + renamed = str(title).strip() + if not renamed: + raise cmuxError("rename_workspace requires a non-empty title") + wsid = self._resolve_workspace_id(workspace) + params: Dict[str, Any] = {"title": renamed} + if wsid: + params["workspace_id"] = wsid + self._call("workspace.rename", params) + def current_workspace(self) -> str: wsid = self._resolve_workspace_id(None) if not wsid: raise cmuxError("No current workspace") return wsid + def next_workspace(self) -> str: + res = self._call("workspace.next") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.next returned no workspace_id: {res}") + return str(wsid) + + def previous_workspace(self) -> str: + res = self._call("workspace.previous") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.previous returned no workspace_id: {res}") + return str(wsid) + + def last_workspace(self) -> str: + res = self._call("workspace.last") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.last returned no workspace_id: {res}") + return str(wsid) + def move_workspace_to_window(self, workspace: Union[str, int], window_id: str, focus: bool = True) -> None: wsid = self._resolve_workspace_id(workspace) self._call( @@ -639,6 +670,18 @@ class cmux: res = self._call("surface.health", params) or {} return list(res.get("surfaces") or []) + def clear_history(self, surface: Union[str, int, None] = None, workspace: Union[str, int, None] = None) -> None: + params: Dict[str, Any] = {} + if workspace is not None: + wsid = self._resolve_workspace_id(workspace) + params["workspace_id"] = wsid + if surface is not None: + sid = self._resolve_surface_id(surface, workspace_id=params.get("workspace_id")) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("surface.clear_history", params) + # --------------------------------------------------------------------- # Pane commands # --------------------------------------------------------------------- @@ -677,6 +720,61 @@ class cmux: )) return out + def swap_pane(self, pane: Union[str, int], target_pane: Union[str, int], focus: bool = True) -> None: + source = self._resolve_pane_id(pane) + target = self._resolve_pane_id(target_pane) + if not source or not target: + raise cmuxError(f"Invalid panes: pane={pane!r}, target_pane={target_pane!r}") + self._call("pane.swap", {"pane_id": source, "target_pane_id": target, "focus": bool(focus)}) + + def break_pane(self, pane: Union[str, int, None] = None, surface: Union[str, int, None] = None, focus: bool = True) -> str: + params: Dict[str, Any] = {"focus": bool(focus)} + if pane is not None: + pid = self._resolve_pane_id(pane) + if not pid: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = pid + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + res = self._call("pane.break", params) or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"pane.break returned no workspace_id: {res}") + return str(wsid) + + def join_pane( + self, + target_pane: Union[str, int], + pane: Union[str, int, None] = None, + surface: Union[str, int, None] = None, + focus: bool = True, + ) -> None: + target = self._resolve_pane_id(target_pane) + if not target: + raise cmuxError(f"Invalid target_pane: {target_pane!r}") + params: Dict[str, Any] = {"target_pane_id": target, "focus": bool(focus)} + if pane is not None: + source = self._resolve_pane_id(pane) + if not source: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = source + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("pane.join", params) + + def last_pane(self) -> str: + res = self._call("pane.last") or {} + pid = res.get("pane_id") + if not pid: + raise cmuxError(f"pane.last returned no pane_id: {res}") + return str(pid) + # --------------------------------------------------------------------- # Input # --------------------------------------------------------------------- diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py new file mode 100644 index 00000000..13e564c1 --- /dev/null +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression: tmux rename-window parity via workspace.rename + CLI aliases.""" + +import glob +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import 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]) -> str: + env = dict(os.environ) + # Keep this test deterministic when running from inside another cmux shell. + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + 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 _workspace_title(c: cmux, workspace_id: str) -> str: + payload = c._call("workspace.list") or {} + for row in payload.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return str(row.get("title") or "") + raise cmuxError(f"workspace.list missing workspace {workspace_id}: {payload}") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("workspace.rename" in methods, f"Missing workspace.rename in capabilities: {sorted(methods)[:30]}") + + 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}") + c._call("workspace.select", {"workspace_id": ws_id}) + + api_title = f"tmux-api-{stamp}" + c.rename_workspace(api_title, workspace=ws_id) + _must(_workspace_title(c, ws_id) == api_title, "workspace.rename API did not update workspace title") + + cli_title = f"tmux cli {stamp}" + _run_cli(cli, ["rename-workspace", "--workspace", ws_id, cli_title]) + _must(_workspace_title(c, ws_id) == cli_title, "cmux rename-workspace did not update workspace title") + + alias_title = f"tmux alias {stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws_id, alias_title]) + _must(_workspace_title(c, ws_id) == alias_title, "cmux rename-window did not update workspace title") + + current_title = f"tmux current {stamp}" + _run_cli(cli, ["rename-window", current_title]) + _must( + _workspace_title(c, ws_id) == current_title, + "cmux rename-window without --workspace should target current workspace", + ) + + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "rename-window", "--workspace", ws_id], + capture_output=True, + text=True, + check=False, + env=env, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected rename-window without title to fail") + _must( + "rename-window requires a title" in invalid_output, + f"Unexpected error for rename-window without title: {invalid_output!r}", + ) + + print("PASS: tmux rename-window parity works via workspace.rename and CLI aliases") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py new file mode 100644 index 00000000..876e4130 --- /dev/null +++ b/tests_v2/test_tmux_compat_matrix.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Regression: tmux compatibility command matrix (implemented + explicit not-supported).""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Callable, List, Tuple + +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 _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +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], *, expect_ok: bool = True) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if expect_ok and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc + + +def _pane_selected_surface(c: cmux, pane_id: str) -> str: + rows = c.list_pane_surfaces(pane_id) + for _idx, sid, _title, selected in rows: + if selected: + return sid + if rows: + return rows[0][1] + raise cmuxError(f"pane {pane_id} has no surfaces") + + +def _pane_surface_ids(c: cmux, pane_id: str) -> List[str]: + rows = c.list_pane_surfaces(pane_id) + return [sid for _idx, sid, _title, _selected in rows] + + +def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + payload = c._call("surface.read_text", {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}) or {} + return token in str(payload.get("text") or "") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + for method in [ + "workspace.next", + "workspace.previous", + "workspace.last", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", + "surface.clear_history", + ]: + _must(method in methods, f"Missing capability {method!r}") + + ws = c.new_workspace() + c.select_workspace(ws) + _ = c.new_split("right") + time.sleep(0.2) + + panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(panes) >= 2, f"Expected >=2 panes, got {panes}") + p1, p2 = panes[0], panes[1] + + s1 = _pane_selected_surface(c, p1) + s2 = _pane_selected_surface(c, p2) + + capture_token = f"TMUX_CAPTURE_{stamp}" + c.send_surface(s1, f"echo {capture_token}\n") + _wait_for(lambda: _surface_has(c, ws, s1, capture_token)) + + cap = _run_cli(cli, ["capture-pane", "--workspace", ws, "--surface", s1, "--scrollback"]) + _must(capture_token in cap.stdout, f"capture-pane missing token: {cap.stdout!r}") + + pipe_file = Path(tempfile.gettempdir()) / f"cmux_pipe_pane_{stamp}.log" + _run_cli(cli, ["pipe-pane", "--workspace", ws, "--surface", s1, "--command", f"cat > {pipe_file}"]) + piped = pipe_file.read_text() if pipe_file.exists() else "" + _must(capture_token in piped, f"pipe-pane output missing token: {piped!r}") + + wait_name = f"tmux_wait_{stamp}" + waiter = _run_cli(cli, ["wait-for", wait_name, "--timeout", "5"], expect_ok=False) + _must(waiter.returncode != 0, "wait-for without signal should time out when run synchronously in test") + signaler = subprocess.Popen( + [cli, "--socket", SOCKET_PATH, "wait-for", wait_name, "--timeout", "5"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID"}}, + ) + time.sleep(0.2) + _run_cli(cli, ["wait-for", "-S", wait_name]) + out, err = signaler.communicate(timeout=5) + _must(signaler.returncode == 0, f"wait-for signal/wait failed: out={out!r} err={err!r}") + + title = f"tmux-title-{stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws, title]) + find = _run_cli(cli, ["find-window", title]) + _must(title in find.stdout, f"find-window title search failed: {find.stdout!r}") + + ws2 = c.new_workspace() + ws3 = c.new_workspace() + c.select_workspace(ws) + c.select_workspace(ws2) + _run_cli(cli, ["last-window"]) + _must(c.current_workspace() == ws, f"last-window should navigate history back to ws={ws}") + _run_cli(cli, ["next-window"]) + _must(c.current_workspace() == ws2, f"next-window should move to ws2={ws2}") + _run_cli(cli, ["previous-window"]) + _must(c.current_workspace() == ws, f"previous-window should move back to ws={ws}") + c.select_workspace(ws) + + pre_p1 = _pane_selected_surface(c, p1) + pre_p2 = _pane_selected_surface(c, p2) + _run_cli(cli, ["swap-pane", "--workspace", ws, "--pane", p1, "--target-pane", p2]) + post_p1_ids = set(_pane_surface_ids(c, p1)) + post_p2_ids = set(_pane_surface_ids(c, p2)) + _must(pre_p2 in post_p1_ids, f"swap-pane should move target surface into source pane (p1={post_p1_ids}, pre_p2={pre_p2})") + _must(pre_p1 in post_p2_ids, f"swap-pane should move source surface into target pane (p2={post_p2_ids}, pre_p1={pre_p1})") + + s_break = _pane_selected_surface(c, p1) + br = _run_cli(cli, ["--json", "--id-format", "both", "break-pane", "--workspace", ws, "--surface", s_break]) + br_payload = json.loads(br.stdout or "{}") + ws_break = str(br_payload.get("workspace_id") or "") + _must(bool(ws_break), f"break-pane returned invalid payload: {br_payload}") + _must(ws_break in [wid for _idx, wid, _title, _sel in c.list_workspaces()], "break-pane workspace missing from list") + _run_cli(cli, ["join-pane", "--workspace", ws, "--surface", s_break, "--target-pane", p2]) + _must(s_break in _pane_surface_ids(c, p2), f"join-pane should move broken surface into target pane {p2}") + + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + if len(current_panes) < 2: + _ = c.new_split("right") + time.sleep(0.2) + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(current_panes) >= 2, f"Expected >=2 panes after break/join, got {current_panes}") + lp_source, lp_target = current_panes[0], current_panes[1] + + c.focus_pane(lp_source) + c.focus_pane(lp_target) + _run_cli(cli, ["last-pane", "--workspace", ws]) + ident = c.identify() + focused = ident.get("focused") or {} + _must( + str(focused.get("pane_id") or "") == lp_source, + f"last-pane should focus previous pane {lp_source}, focused={focused}", + ) + + _run_cli(cli, ["clear-history", "--workspace", ws, "--surface", s1]) + + _run_cli(cli, ["set-hook", "workspace-created", "echo created"]) + hooks = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" in hooks.stdout, f"set-hook --list missing stored hook: {hooks.stdout!r}") + _run_cli(cli, ["set-hook", "--unset", "workspace-created"]) + hooks2 = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" not in hooks2.stdout, f"set-hook --unset failed: {hooks2.stdout!r}") + + for cmd in (["popup"], ["bind-key", "C-b", "split-window"], ["unbind-key", "C-b"], ["copy-mode"]): + proc = _run_cli(cli, cmd, expect_ok=False) + merged = f"{proc.stdout}\n{proc.stderr}".lower() + _must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}") + + resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False) + _must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added") + + buffer_token = f"TMUX_BUFFER_{stamp}" + _run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"]) + buffers = _run_cli(cli, ["list-buffers"]) + _must("tmuxbuf" in buffers.stdout, f"list-buffers missing tmuxbuf: {buffers.stdout!r}") + _run_cli(cli, ["paste-buffer", "--name", "tmuxbuf", "--workspace", ws, "--surface", s1]) + _wait_for(lambda: _surface_has(c, ws, s1, buffer_token)) + + respawn_token = f"TMUX_RESPAWN_{stamp}" + _run_cli(cli, ["respawn-pane", "--workspace", ws, "--surface", s1, "--command", f"echo {respawn_token}"]) + _wait_for(lambda: _surface_has(c, ws, s1, respawn_token)) + + msg = f"tmux-message-{stamp}" + shown = _run_cli(cli, ["display-message", "-p", msg]) + _must(msg in shown.stdout, f"display-message -p should print message: {shown.stdout!r}") + + print("PASS: tmux compatibility matrix commands are wired and tested") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())