From d72fcfae7acd93784af13b5e9a67ff93688cf564 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Sat, 26 Sep 2026 10:12:16 -0400 Subject: [PATCH 1/2] Test that set-buffer keeps text exactly and reads stdin These fail on main: set-buffer trims leading and trailing whitespace and newlines from its argument, and with no argument it errors instead of reading stdin (a lone `-` is stored literally). Co-Authored-By: Claude Opus 5.5 (1M context) --- cmux.xcodeproj/project.pbxproj | 4 + .../CLITmuxCompatBufferContentTests.swift | 131 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 cmuxCLITests/CLITmuxCompatBufferContentTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c1355c0d22a5..e0ef8f732811 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -643,6 +643,7 @@ C11757000000000000000033 /* CLIStdioSIGPIPERegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */; }; C11757000000000000000045 /* CLITestBundleAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000053 /* CLITestBundleAnchor.swift */; }; C11757000000000000000054 /* CLITestBundleAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000053 /* CLITestBundleAnchor.swift */; }; + C117570000000000000000B2 /* CLITmuxCompatBufferContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */; }; B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */; }; 7837E0057837E0057837E005 /* CLITmuxCompatResizePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */; }; C11757000000000000000034 /* CLITmuxCompatStoreConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */; }; @@ -4745,6 +4746,7 @@ A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISSHSessionAttachAnchorTests.swift; sourceTree = ""; }; C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIStdioSIGPIPERegressionTests.swift; sourceTree = ""; }; C11757000000000000000053 /* CLITestBundleAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITestBundleAnchor.swift; sourceTree = ""; }; + C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatBufferContentTests.swift; sourceTree = ""; }; B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatRemoteSplitTests.swift; sourceTree = ""; }; 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatResizePaneTests.swift; sourceTree = ""; }; C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatStoreConcurrencyTests.swift; sourceTree = ""; }; @@ -8178,6 +8180,7 @@ C11757000000000000000019 /* CLISSHPTYResizeInputTests.swift */, C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */, C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */, + C117570000000000000000B1 /* CLITmuxCompatBufferContentTests.swift */, C1175700000000000000001C /* CLIWindowHandleRoutingTests.swift */, C1175700000000000000001D /* CLIWorkspaceGroupSafetyTests.swift */, C1175700000000000000001E /* CLIWorkspaceStableIDTests.swift */, @@ -12848,6 +12851,7 @@ C11757000000000000000032 /* CLISSHPTYResizeInputTests.swift in Sources */, C11757000000000000000033 /* CLIStdioSIGPIPERegressionTests.swift in Sources */, C11757000000000000000045 /* CLITestBundleAnchor.swift in Sources */, + C117570000000000000000B2 /* CLITmuxCompatBufferContentTests.swift in Sources */, C11757000000000000000034 /* CLITmuxCompatStoreConcurrencyTests.swift in Sources */, C11757000000000000000046 /* CLIWindowCommandMockServer.swift in Sources */, C11757000000000000000035 /* CLIWindowHandleRoutingTests.swift in Sources */, diff --git a/cmuxCLITests/CLITmuxCompatBufferContentTests.swift b/cmuxCLITests/CLITmuxCompatBufferContentTests.swift new file mode 100644 index 000000000000..c109bb0dbe9f --- /dev/null +++ b/cmuxCLITests/CLITmuxCompatBufferContentTests.swift @@ -0,0 +1,131 @@ +import Darwin +import Foundation +import Testing + +/// `cmux set-buffer` stores exactly the text it is given, including trailing +/// newlines and indentation, and reads that text from stdin when no text +/// argument is passed, so `cmd | cmux set-buffer` works like tmux. +@Suite(.serialized) +struct CLITmuxCompatBufferContentTests { + @Test func setBufferReadsStandardInputVerbatim() throws { + let text = "first line\n indented line\n\n" + let run = try runSetBuffer(arguments: ["set-buffer", "--name", "piped"], standardInput: text) + + #expect(run.result.status == 0, Comment(rawValue: run.result.stderr)) + #expect(run.buffers?["piped"] == text) + } + + @Test func setBufferDashReadsStandardInput() throws { + let run = try runSetBuffer(arguments: ["set-buffer", "--name", "dash", "-"], standardInput: "from stdin\n") + + #expect(run.result.status == 0, Comment(rawValue: run.result.stderr)) + #expect(run.buffers?["dash"] == "from stdin\n") + } + + @Test func setBufferKeepsTrailingNewlineAndSpacesFromArguments() throws { + let run = try runSetBuffer(arguments: ["set-buffer", "--name", "arg", "--", " echo hi\n"]) + + #expect(run.result.status == 0, Comment(rawValue: run.result.stderr)) + #expect(run.buffers?["arg"] == " echo hi\n") + } + + @Test func setBufferWithEmptyStandardInputFailsWithoutWriting() throws { + let run = try runSetBuffer(arguments: ["set-buffer", "--name", "empty"], standardInput: "") + + #expect(run.result.status != 0) + #expect(run.buffers?["empty"] == nil) + } + + // MARK: - Harness + + private struct Run { + let result: CLIHookProcessRunner.Result + let buffers: [String: String]? + } + + private func runSetBuffer(arguments: [String], standardInput: String? = nil) throws -> Run { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tmux-buffer-content-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let storeURL = home + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("tmux-compat-store.json", isDirectory: false) + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let socketPath = makeCodexHookSocketPath("setbuf") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + let drain = Self.startClientDrain(listenerFD: listenerFD) + defer { + drain.stop.set() + _ = drain.done.wait(timeout: .now() + 5) + Darwin.close(listenerFD) + unlink(socketPath) + } + + let result = CLIHookProcessRunner.run( + executablePath: try BundledCLITestSupport.bundledCLIPath(for: CLITestBundleAnchor.self), + arguments: arguments, + environment: [ + "CMUX_SOCKET_PATH": socketPath, + "CMUX_SOCKET_PASSWORD": "", + "CMUX_CLI_SENTRY_DISABLED": "1", + "CFFIXED_USER_HOME": home.path, + "HOME": home.path, + "PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin", + ], + standardInput: standardInput, + timeout: 60 + ) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + + var buffers: [String: String]? + if let data = try? Data(contentsOf: storeURL), + let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + buffers = object["buffers"] as? [String: String] + } + return Run(result: result, buffers: buffers) + } + + private final class StopFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } + + func set() { + lock.lock() + value = true + lock.unlock() + } + } + + /// Accepts and immediately closes clients until stopped; set-buffer only + /// touches the local store, so no socket replies are needed. + private static func startClientDrain(listenerFD: Int32) -> (done: DispatchSemaphore, stop: StopFlag) { + let done = DispatchSemaphore(value: 0) + let stop = StopFlag() + DispatchQueue.global(qos: .userInitiated).async { + defer { done.signal() } + while !stop.isSet { + var descriptor = pollfd(fd: listenerFD, events: Int16(POLLIN), revents: 0) + let ready = Darwin.poll(&descriptor, 1, 100) + if ready < 0 { + if errno == EINTR { continue } + return + } + guard ready > 0 else { continue } + let clientFD = Darwin.accept(listenerFD, nil, nil) + if clientFD >= 0 { Darwin.close(clientFD) } + } + } + return (done, stop) + } +} From f2d114fbb010b6c9cf77fa30e66acd66b91289e8 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Sat, 26 Sep 2026 10:15:44 -0400 Subject: [PATCH 2/2] Keep set-buffer text exact and read it from stdin set-buffer trimmed whitespace and newlines from its text, so a buffer could never end in a newline or keep leading indentation, and it had no way to take piped input. It now stores the text exactly as given, like tmux, and reads stdin when no text argument (or a lone `-`) is passed, so `cmd | cmux set-buffer` followed by `cmux paste-buffer` moves output between panes intact. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+TaskHelp.swift | 2 +- CLI/cmux.swift | 26 +++++++++++++-- Resources/Localizable.xcstrings | 59 +++++++++++++++++++++++++++++++++ docs/cli-contract.md | 2 +- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 39117c3db711..a76009f8a361 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -428,7 +428,7 @@ extension CMUXCLI { set-hook [--list] [--unset ] | popup bind-key | unbind-key | copy-mode - set-buffer [--name ] + set-buffer [--name ] [ | -] list-buffers paste-buffer [--name ] [--workspace ] [--surface ] [--window ] respawn-pane [--workspace ] [--surface ] [--window ] [--command ] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4589608320cd..954b02073b25 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19826,9 +19826,9 @@ struct CMUXCLI { """ case "set-buffer": return """ - Usage: cmux set-buffer [--name ] [--] + Usage: cmux set-buffer [--name ] [--] [ | -] - Save text into a named tmux-compat buffer. + Save text into a named tmux-compat buffer, exactly as given. With no text argument, or with -, the text is read from stdin. Flags: --name Buffer name (default: default) @@ -27359,7 +27359,27 @@ struct CMUXCLI { 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) + // Store the text exactly as given, like tmux: trailing newlines and + // indentation are part of what paste-buffer should deliver. With no + // text argument, or a lone "-", read the text from stdin so output + // can be piped in (`cmd | cmux set-buffer`). + let textArgs = Array(rem0.dropFirst(rem0.first == "--" ? 1 : 0)) + let content: String + if textArgs.isEmpty || textArgs == ["-"] { + guard isatty(STDIN_FILENO) != 1 else { + throw CLIError(message: "set-buffer requires text") + } + let data = FileHandle.standardInput.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { + throw CLIError(message: String( + localized: "cli.setBuffer.error.invalidUTF8", + defaultValue: "set-buffer: stdin is not valid UTF-8 text" + )) + } + content = text + } else { + content = textArgs.joined(separator: " ") + } guard !content.isEmpty else { throw CLIError(message: "set-buffer requires text") } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0ff3f0ff963f..abb7cab42610 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -558407,6 +558407,65 @@ } } } + }, + "cli.setBuffer.error.invalidUTF8": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: stdin is not valid UTF-8 text" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: محتوى stdin ليس نص UTF-8 صالحًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: stdin ist kein gültiger UTF-8-Text" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: stdin no es texto UTF-8 válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "set-buffer : stdin n’est pas un texte UTF-8 valide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: stdin が有効な UTF-8 テキストではありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "set-buffer: stdin이 유효한 UTF-8 텍스트가 아닙니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "set-buffer:stdin 不是有效的 UTF-8 文本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "set-buffer:stdin 不是有效的 UTF-8 文字" + } + } + } } }, "version": "1.0" diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 871e39d47b17..6c49132f13aa 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -525,7 +525,7 @@ tmux compatibility commands: | `set-hook` | Manage tmux-compat hook definitions. | | `popup` | Placeholder, currently unsupported. | | `bind-key`, `unbind-key`, `copy-mode` | Placeholders, currently unsupported. | -| `set-buffer` | Set a tmux-compat buffer. | +| `set-buffer` | Set a tmux-compat buffer to the given text exactly; reads stdin when no text (or `-`) is given. | | `paste-buffer` | Paste a tmux-compat buffer. | | `list-buffers` | List tmux-compat buffers. | | `respawn-pane` | Send a restart command to a surface. |