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:
parent
6f1e100db6
commit
6cb282bf09
5 changed files with 1440 additions and 0 deletions
548
CLI/cmux.swift
548
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 <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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------
|
||||
|
|
|
|||
118
tests_v2/test_rename_window_workspace_parity.py
Normal file
118
tests_v2/test_rename_window_workspace_parity.py
Normal 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())
|
||||
232
tests_v2/test_tmux_compat_matrix.py
Normal file
232
tests_v2/test_tmux_compat_matrix.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue