tmux compat: implement issue-153 command set with matrix tests (#221)

* Add tmux rename-window workspace compatibility

Implement workspace.rename in the v2 API and wire CLI commands rename-workspace/rename-window with help text.

Add a regression test that validates API and CLI rename parity plus error handling.

Refs: https://github.com/manaflow-ai/cmux/issues/153

* Add full tmux compatibility command matrix and regression coverage
This commit is contained in:
Lawrence Chen 2026-02-20 18:22:26 -08:00 committed by GitHub
parent 6f1e100db6
commit 6cb282bf09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1440 additions and 0 deletions

View file

@ -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 <id|ref>] [--] <title>
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]

View file

@ -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 {

View file

@ -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
# ---------------------------------------------------------------------

View file

@ -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())

View file

@ -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())