Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 84 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -11626,7 +11632,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,
Expand Down Expand Up @@ -11689,7 +11695,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":
Expand All @@ -11712,6 +11718,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.
Expand All @@ -11720,19 +11727,36 @@ 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) ?? [:]
}

init() {}
}

private func tmuxCompatDefaultOptionValue(_ name: String) -> String? {
switch name.lowercased() {
case "extended-keys":
// cmux is not a tmux server, but OMX expects the tmux compatibility
// shim to answer lease-management probes for extended-keys.
return "off"
default:
return nil
}
}

private func tmuxCompatOptionValue(_ name: String, store: TmuxCompatStore) -> String? {
let normalizedName = name.lowercased()
return store.options[normalizedName] ?? tmuxCompatDefaultOptionValue(normalizedName)
}

private func tmuxCompatStoreURL() -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"]
?? NSString(string: "~").expandingTildeInPath
Expand Down Expand Up @@ -12204,6 +12228,61 @@ 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()
guard let optionName = parsed.positional.first?
.trimmingCharacters(in: .whitespacesAndNewlines),
!optionName.isEmpty else {
throw CLIError(message: "\(command) requires an option name")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 show-options without a name argument errors instead of listing all options

In real tmux, show-options (or show-options -g) with no positional argument lists every option's current value. The guard here throws a CLIError when no name is supplied, so any caller that enumerates all options will get an error rather than the options map. Consider returning the full store.options merged with built-in defaults when no positional name is provided.

}

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)")
}

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 value = parsed.positional.dropFirst().joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw CLIError(message: "\(command) requires an option value")
}

store.options[normalizedName] = value
try saveTmuxCompatStore(store)

Comment on lines +12308 to +12345
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Implement parsed -o / -a behavior in set-option.

Line 12261 parses -o and -a, but Lines 12283-12284 always overwrite the value. That diverges from tmux semantics and can break scripts expecting conditional set or append behavior.

🔧 Suggested patch
         case "set-option", "set", "set-window-option", "setw":
             let parsed = try parseTmuxArguments(
                 commandArgs,
                 valueFlags: [],
                 boolFlags: ["-F", "-a", "-g", "-o", "-p", "-q", "-s", "-u", "-w"]
             )
@@
             if parsed.hasFlag("-u") {
                 store.options.removeValue(forKey: normalizedName)
                 try saveTmuxCompatStore(store)
                 return
             }
@@
-            store.options[normalizedName] = value
+            if parsed.hasFlag("-o"), store.options[normalizedName] != nil {
+                return
+            }
+
+            if parsed.hasFlag("-a"), let existing = store.options[normalizedName] {
+                store.options[normalizedName] = existing + value
+            } else {
+                store.options[normalizedName] = value
+            }
             try saveTmuxCompatStore(store)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 12257 - 12285, The current code always
overwrites store.options[normalizedName] but parseTmuxArguments included flags
for "-o" (only-set) and "-a" (append); update the set-option handling to honor
these flags: after computing normalizedName and value, if parsed.hasFlag("-o")
then only set store.options[normalizedName] if it does not already exist; if
parsed.hasFlag("-a") then read existing = store.options[normalizedName] and set
store.options[normalizedName] = existing.isEmpty ? value :
"\(existing),\(value)" (or similar list-append logic), otherwise perform the
current overwrite; finally call try saveTmuxCompatStore(store) as before.

case "popup":
throw CLIError(message: "popup is not supported yet in cmux CLI parity mode")

Expand Down Expand Up @@ -14393,6 +14472,8 @@ struct CMUXCLI {
last-pane [--workspace <id|ref>]
find-window [--content] [--select] <query>
clear-history [--workspace <id|ref>] [--surface <id|ref>]
show-options [-sv] <name>
set-option [-sq] <name> <value>
set-hook [--list] [--unset <event>] | <event> <command>
popup
bind-key | unbind-key | copy-mode
Expand Down
7 changes: 7 additions & 0 deletions tests_v2/test_tmux_compat_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ 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"])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Use -u to unset instead of re-setting to the default value

The teardown sets extended-keys back to "off" via set-option, which persists the entry in the store. Using set-option -u extended-keys would cleanly remove the key, restoring the store to its pre-test state and matching OMX's actual lease-release behavior (which calls -u to relinquish ownership).

Suggested change
_run_cli(cli, ["set-option", "-sq", "extended-keys", "off"])
_run_cli(cli, ["set-option", "-u", "extended-keys"])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current OMX restores extended-keys with set-option -sq ..., not -u


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}")

Expand Down