diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 20b729ecb..ca0cc7408 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2694,7 +2694,13 @@ struct CMUXCLI { "bind-key", "unbind-key", "copy-mode", + "show-option", + "show-options", + "set", + "set-option", + "set-window-option", "set-buffer", + "setw", "paste-buffer", "list-buffers", "respawn-pane", @@ -6871,6 +6877,37 @@ struct CMUXCLI { cmux omc team 3:claude "implement feature" cmux omc --watch """) + case "show-option", "show-options": + return """ + Usage: cmux show-options [-svq] [name] + + Show tmux-compat option values. Without a name, list all known values. + + Flags: + -s Server option scope compatibility flag + -v Print only the option value + -q Suppress errors for missing or unsupported options + + Examples: + cmux show-options + cmux show-options -sv extended-keys + """ + case "set", "set-option", "set-window-option", "setw": + return """ + Usage: cmux set-option [-aouq] + + Persist a tmux-compat option value. + + Flags: + -a Append to the existing value + -o Only set if the option is not already set + -u Unset the option + -q Suppress errors for unknown options + + Examples: + cmux set-option extended-keys always + cmux set-option -u extended-keys + """ case "identify": return """ Usage: cmux identify [--workspace ] [--surface ] [--no-caller] @@ -11626,7 +11663,7 @@ struct CMUXCLI { print(buffer) } - case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers": + case "last-window", "next-window", "previous-window", "set-hook", "show-option", "show-options", "set", "set-option", "set-window-option", "set-buffer", "list-buffers", "setw": try runTmuxCompatCommand( command: command, commandArgs: rawArgs, @@ -11689,7 +11726,7 @@ struct CMUXCLI { try tmuxPruneCompatWorkspaceState(workspaceId: workspaceId) } - case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": + case "source-file", "refresh-client", "attach-session", "detach-client": return case "-V", "-v": @@ -11712,6 +11749,7 @@ struct CMUXCLI { private struct TmuxCompatStore: Codable { var buffers: [String: String] = [:] var hooks: [String: String] = [:] + var options: [String: String] = [:] /// Tracks main-vertical layout state per workspace, keyed by workspace ID. var mainVerticalLayouts: [String: MainVerticalState] = [:] /// Tracks the last surface created by split-window per workspace. @@ -11720,12 +11758,13 @@ struct CMUXCLI { var lastSplitSurface: [String: String] = [:] /// Custom decoder so older store files missing newer keys - /// (mainVerticalLayouts, lastSplitSurface) decode gracefully + /// (options, mainVerticalLayouts, lastSplitSurface) decode gracefully /// instead of throwing and resetting the entire store. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) buffers = try container.decodeIfPresent([String: String].self, forKey: .buffers) ?? [:] hooks = try container.decodeIfPresent([String: String].self, forKey: .hooks) ?? [:] + options = try container.decodeIfPresent([String: String].self, forKey: .options) ?? [:] mainVerticalLayouts = try container.decodeIfPresent([String: MainVerticalState].self, forKey: .mainVerticalLayouts) ?? [:] lastSplitSurface = try container.decodeIfPresent([String: String].self, forKey: .lastSplitSurface) ?? [:] } @@ -11733,6 +11772,27 @@ struct CMUXCLI { init() {} } + private func tmuxCompatDefaultOptions() -> [String: String] { + [ + // cmux is not a tmux server, but OMX expects the tmux compatibility + // shim to answer lease-management probes for extended-keys. + "extended-keys": "off" + ] + } + + private func tmuxCompatDefaultOptionValue(_ name: String) -> String? { + tmuxCompatDefaultOptions()[name.lowercased()] + } + + private func tmuxCompatMergedOptions(store: TmuxCompatStore) -> [String: String] { + tmuxCompatDefaultOptions().merging(store.options) { _, stored in stored } + } + + private func tmuxCompatOptionValue(_ name: String, store: TmuxCompatStore) -> String? { + let normalizedName = name.lowercased() + return tmuxCompatMergedOptions(store: store)[normalizedName] + } + private func tmuxCompatStoreURL() -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSString(string: "~").expandingTildeInPath @@ -12204,6 +12264,85 @@ struct CMUXCLI { try saveTmuxCompatStore(store) print("OK") + case "show-option", "show-options": + let parsed = try parseTmuxArguments( + commandArgs, + valueFlags: [], + boolFlags: ["-g", "-p", "-q", "-s", "-v", "-w"] + ) + let store = loadTmuxCompatStore() + let optionName = parsed.positional.first? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let optionName, !optionName.isEmpty { + guard let value = tmuxCompatOptionValue(optionName, store: store) else { + if parsed.hasFlag("-q") { + return + } + throw CLIError(message: "Unsupported tmux compatibility option: \(optionName)") + } + + if parsed.hasFlag("-v") { + print(value) + } else { + print("\(optionName) \(value)") + } + return + } + + let allOptions = tmuxCompatMergedOptions(store: store) + if allOptions.isEmpty { + if parsed.hasFlag("-q") { + return + } + throw CLIError(message: "No tmux compatibility options are set") + } + for (name, value) in allOptions.sorted(by: { $0.key < $1.key }) { + if parsed.hasFlag("-v") { + print(value) + } else { + print("\(name) \(value)") + } + } + + case "set-option", "set", "set-window-option", "setw": + let parsed = try parseTmuxArguments( + commandArgs, + valueFlags: [], + boolFlags: ["-F", "-a", "-g", "-o", "-p", "-q", "-s", "-u", "-w"] + ) + guard let optionName = parsed.positional.first? + .trimmingCharacters(in: .whitespacesAndNewlines), + !optionName.isEmpty else { + throw CLIError(message: "\(command) requires an option name") + } + + var store = loadTmuxCompatStore() + let normalizedName = optionName.lowercased() + if parsed.hasFlag("-u") { + store.options.removeValue(forKey: normalizedName) + try saveTmuxCompatStore(store) + return + } + + let valueParts = Array(parsed.positional.dropFirst()) + guard !valueParts.isEmpty else { + throw CLIError(message: "\(command) requires an option value") + } + let value = valueParts.joined(separator: " ") + + if parsed.hasFlag("-o"), tmuxCompatOptionValue(normalizedName, store: store) != nil { + return + } + + if parsed.hasFlag("-a") { + let existing = tmuxCompatOptionValue(normalizedName, store: store) ?? "" + store.options[normalizedName] = existing + value + } else { + store.options[normalizedName] = value + } + try saveTmuxCompatStore(store) + case "popup": throw CLIError(message: "popup is not supported yet in cmux CLI parity mode") @@ -14393,6 +14532,8 @@ struct CMUXCLI { last-pane [--workspace ] find-window [--content] [--select] clear-history [--workspace ] [--surface ] + show-options [-sv] [name] + set-option [-asq] set-hook [--list] [--unset ] | popup bind-key | unbind-key | copy-mode diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py index 59ee3d3ac..3ec4a9314 100644 --- a/tests_v2/test_tmux_compat_matrix.py +++ b/tests_v2/test_tmux_compat_matrix.py @@ -157,6 +157,45 @@ def main() -> int: c.send_surface(s1, f"echo {capture_token}\n") _wait_for(lambda: _surface_has(c, ws, s1, capture_token)) + show_extended_keys = _run_cli(cli, ["show-options", "-sv", "extended-keys"]) + _must(show_extended_keys.stdout.strip() == "off", f"show-options should default extended-keys to off, got {show_extended_keys.stdout!r}") + _run_cli(cli, ["set-option", "-sq", "extended-keys", "always"]) + show_extended_keys = _run_cli(cli, ["show-options", "-sv", "extended-keys"]) + _must(show_extended_keys.stdout.strip() == "always", f"set-option should persist extended-keys, got {show_extended_keys.stdout!r}") + _run_cli(cli, ["set-option", "-sq", "extended-keys", "off"]) + show_all_options = _run_cli(cli, ["show-options"]) + _must("extended-keys off" in show_all_options.stdout, f"show-options should list default options, got {show_all_options.stdout!r}") + + custom_option = f"@compat_probe_{stamp}" + _run_cli(cli, ["set-option", custom_option, "alpha"]) + _run_cli(cli, ["set-option", "-o", custom_option, "beta"]) + custom_value = _run_cli(cli, ["show-options", "-v", custom_option]) + _must(custom_value.stdout.strip() == "alpha", f"set-option -o should keep the existing value, got {custom_value.stdout!r}") + _run_cli(cli, ["set-option", "-a", custom_option, "gamma"]) + custom_value = _run_cli(cli, ["show-options", "-v", custom_option]) + _must(custom_value.stdout.strip() == "alphagamma", f"set-option -a should append to the existing value, got {custom_value.stdout!r}") + show_all_options = _run_cli(cli, ["show-options"]) + _must(f"{custom_option} alphagamma" in show_all_options.stdout, f"show-options should list custom options, got {show_all_options.stdout!r}") + _run_cli(cli, ["set-option", "-u", custom_option]) + + show_help = _run_cli(cli, ["show-options", "--help"]) + _must("Usage: cmux show-options" in show_help.stdout, f"show-options --help should print usage, got {show_help.stdout!r}") + set_help = _run_cli(cli, ["set-option", "--help"]) + _must("Usage: cmux set-option" in set_help.stdout, f"set-option --help should print usage, got {set_help.stdout!r}") + + empty_option = f"@empty_probe_{stamp}" + _run_cli(cli, ["set-option", empty_option, ""]) + empty_value = _run_cli(cli, ["show-options", "-v", empty_option]) + _must(empty_value.stdout == "\n", f"set-option should preserve explicit empty values, got {empty_value.stdout!r}") + _run_cli(cli, ["set-option", "-u", empty_option]) + + spaced_option = f"@space_probe_{stamp}" + spaced_value = " padded value " + _run_cli(cli, ["set-option", spaced_option, spaced_value]) + shown_spaced_value = _run_cli(cli, ["show-options", "-v", spaced_option]) + _must(shown_spaced_value.stdout == spaced_value + "\n", f"set-option should preserve leading/trailing whitespace, got {shown_spaced_value.stdout!r}") + _run_cli(cli, ["set-option", "-u", spaced_option]) + 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}")