From 73e317bd364da71e16d18de0f41fd4ca9a2ae441 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Thu, 11 Jun 2026 17:21:02 -0400 Subject: [PATCH 1/2] Cancel tmux copy-mode before paste-buffer in TmuxBackend.sendText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crow's bundled crow-tmux.conf keeps `mouse on` so wheel scrollback works through the cockpit (#452). The side effect is that tmux's default WheelUpPane puts the pane into copy-mode, where `paste-buffer` does not deliver content into the underlying shell. Programmatic sends — Manager paste, auto-respond prompts, quick actions — silently fail whenever the user happened to scroll first. Fix the send path: pre-cancel copy-mode via `if-shell -F '#{pane_in_mode}' 'send-keys -X cancel'` before paste-buffer. Added as a named helper `TmuxController.cancelCopyModeIfActive(target:)` so the intent reads locally and is easy to extend. Also pin WheelUpPane to `copy-mode -e` in crow-tmux.conf so the pane auto-exits copy-mode once wheel-down scrolls past the bottom of history. Matches tmux ≥3.0 defaults; making it explicit insulates Crow from future upstream changes and minimizes time-in-copy-mode. Tests: two real-tmux integration tests in TmuxBackendTests — one forces the pane into copy-mode before sendText and asserts pane_in_mode flips back to 0; one sanity-checks that the if-shell guard is a no-op on a normal pane (no false-failure on every send). Follow-up (deferred to a separate PR): the keystroke-trap leg of #486 — auto-exit copy-mode on a printable keyDown in GhosttySurfaceView. That needs a tmux control-mode `%mode-changed` parser the codebase doesn't have yet; orthogonal to the paste fix this PR closes. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 0E27BE27-E0BC-45A4-BFA1-593A2D112BE2 --- .../CrowTerminal/Resources/crow-tmux.conf | 8 +++ .../Sources/CrowTerminal/TmuxBackend.swift | 11 ++++ .../Sources/CrowTerminal/TmuxController.swift | 15 +++++ .../CrowTerminalTests/TmuxBackendTests.swift | 61 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf b/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf index 3ad2ee3..ebd5d3e 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf +++ b/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf @@ -76,6 +76,14 @@ bind -T root MouseDown1Pane select-pane -t = \; if -F -t = "#{mouse_any_flag}" " bind -T root DoubleClick1Pane select-pane -t = \; if -F -t = "#{mouse_any_flag}" "send-keys -M" { if -F -t = "#{pane_in_mode}" "send-keys -M" { copy-mode -H ; send-keys -X select-word ; run -d0.3 ; send-keys -X copy-pipe-no-clear "pbcopy" } } bind -T root TripleClick1Pane select-pane -t = \; if -F -t = "#{mouse_any_flag}" "send-keys -M" { if -F -t = "#{pane_in_mode}" "send-keys -M" { copy-mode -H ; send-keys -X select-line ; run -d0.3 ; send-keys -X copy-pipe-no-clear "pbcopy" } } +# Pin WheelUpPane to `copy-mode -e` so the pane auto-exits copy-mode once +# wheel-down scrolls back past the bottom of history. Matches tmux ≥3.0 +# defaults, but making it explicit insulates Crow from upstream changes +# and pairs with the send-path copy-mode cancel in TmuxBackend.sendText +# (#486) — together they minimize the time a pane sits stuck in copy-mode +# after the user scrolls. +bind -T root WheelUpPane if-shell -F -t = "#{mouse_any_flag}" "send-keys -M" { if-shell -F -t = "#{pane_in_mode}" "send-keys -M" "copy-mode -et=" } + # Default history-limit is 2000 lines per pane. Phase 3 §2 (the spike) # measured 5 windows × 10k lines as a 400kB RSS delta, so we have plenty # of headroom. 5000 matches typical session-detail expectations. diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift index 9251a32..c410c02 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift @@ -319,6 +319,13 @@ public final class TmuxBackend { /// auto-respond prompts (which fire when the terminal has been idle) can /// race: the Enter arrives before the TUI finishes handling the paste, /// causing it to be silently dropped (#272). + /// + /// We also pre-cancel copy-mode on the pane before pasting (#486). The + /// bundled `crow-tmux.conf` keeps `mouse on` so wheel scrollback works + /// (#452), but the default `WheelUpPane` puts the pane into copy-mode, + /// where `paste-buffer` does not deliver content. Without the cancel, + /// every programmatic send into a pane the user has scrolled (Manager + /// paste, auto-respond, quick actions) is silently dropped. public func sendText(id: UUID, text: String) throws { guard let windowIndex = bindings[id] else { throw TmuxBackendError.unknownTerminal(id) @@ -334,6 +341,10 @@ public final class TmuxBackend { let bufferName = "crow-\(id.uuidString)" try ctrl.loadBufferFromStdin(name: bufferName, data: Data(payload.utf8)) defer { ctrl.deleteBuffer(name: bufferName) } + // Cancel copy-mode if the user scrolled the pane into it + // — paste-buffer is a no-op while the pane is in a mode + // (#486). + try ctrl.cancelCopyModeIfActive(target: target) try ctrl.pasteBuffer(name: bufferName, target: target) didPaste = true } diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift index e294ead..7d1daa0 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift @@ -239,6 +239,21 @@ public struct TmuxController: Sendable { _ = try? run(["delete-buffer", "-b", name]) } + /// `tmux if-shell -F -t '#{pane_in_mode}' 'send-keys -t -X cancel'`. + /// + /// `send-keys -X cancel` errors when the pane isn't in a mode, so the + /// `if-shell` guard keeps this a no-op in the common case. Called before + /// `paste-buffer` in `TmuxBackend.sendText` so programmatic sends land + /// even when the user scrolled the pane into copy-mode (#486): tmux's + /// default `WheelUpPane` enters copy-mode, and `paste-buffer` doesn't + /// deliver content while the pane is in a mode. + public func cancelCopyModeIfActive(target: String) throws { + try run([ + "if-shell", "-F", "-t", target, "#{pane_in_mode}", + "send-keys -t \(target) -X cancel", + ]) + } + // MARK: - Diagnostic /// `tmux capture-pane -p -t -S -`. Returns the diff --git a/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift b/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift index eff5f60..dd11553 100644 --- a/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift +++ b/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift @@ -225,6 +225,67 @@ struct TmuxBackendTests { try backend.sendText(id: id, text: "\n") } + /// Regression for #486: when the user mouse-wheels into copy-mode and + /// Crow then issues a programmatic send (Manager paste, auto-respond, + /// quick action), the pane must be cancelled out of copy-mode before + /// `paste-buffer` runs — otherwise the paste is silently swallowed. + @Test func sendTextCancelsCopyModeBeforePaste() throws { + let backend = makeBackend() + defer { backend.shutdown() } + + let id = UUID() + let binding = try backend.registerTerminal( + id: id, + name: "copy-mode-cancel", + cwd: NSHomeDirectory(), + command: nil, + trackReadiness: false + ) + + // Force the pane into copy-mode via a side-channel controller. + let ctrl = TmuxController( + tmuxBinary: discoveredTmuxBinary!, + socketPath: backend.socketPath, + sessionName: TmuxBackend.cockpitSessionName + ) + let target = "\(TmuxBackend.cockpitSessionName):\(binding.windowIndex)" + _ = try ctrl.run(["copy-mode", "-H", "-t", target]) + + let inModeBefore = try ctrl.run([ + "display-message", "-p", "-t", target, "-F", "#{pane_in_mode}" + ]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(inModeBefore == "1") + + // sendText should pre-cancel copy-mode so the paste actually lands. + try backend.sendText(id: id, text: "PROD2-486-\(UUID().uuidString)") + + let inModeAfter = try ctrl.run([ + "display-message", "-p", "-t", target, "-F", "#{pane_in_mode}" + ]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(inModeAfter == "0") + } + + /// Sanity: the if-shell guard around `send-keys -X cancel` must not + /// error when the pane is NOT in a mode (the common case). Otherwise + /// every send would start failing. + @Test func sendTextOnNormalPaneIsUnaffectedByCancelGuard() throws { + let backend = makeBackend() + defer { backend.shutdown() } + + let id = UUID() + _ = try backend.registerTerminal( + id: id, + name: "no-copy-mode", + cwd: NSHomeDirectory(), + command: nil, + trackReadiness: false + ) + + // Pane is in its normal state. The new cancel-if-active step should + // be a no-op; sendText must still complete cleanly. + try backend.sendText(id: id, text: "PROD2-486-normal-\(UUID().uuidString)\n") + } + @Test func retryReadinessEmitsTimedOutWhenSentinelMissing() async throws { let backend = makeBackend() defer { backend.shutdown() } From 65278a72bb317a5b9499e534e2b58cd8ea4f7257 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Thu, 11 Jun 2026 17:29:20 -0400 Subject: [PATCH 2/2] Hoist copy-mode cancel above paste/Enter split in sendText (#486 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (PR #488) caught that cancelCopyModeIfActive was nested inside the `if !payload.isEmpty` block, so the bare-Enter path (text == "\n", payload empties to "", didPaste stays false) skipped the cancel. A plain `send-keys Enter` into a copy-mode pane is routed through the copy-mode key table (emacs `copy-selection-and-cancel`, vi `cancel`) — exits copy-mode but never delivers a CR to the shell. Same silent-drop failure mode the paste path had before this PR. Hoist the cancel above the paste/Enter split so it runs for every send shape (paste-only, paste+Enter, Enter-only). Updated the doc comment to spell out the bare-Enter path too. Add a regression test sendTextBareEnterCancelsCopyMode that forces the pane into copy-mode then verifies `sendText(id, "\n")` leaves it. Also cosmetic: switch the new WheelUpPane binding from `if-shell` to the `if` alias to match the surrounding MouseDown1Pane / DoubleClick1Pane style in crow-tmux.conf. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 0E27BE27-E0BC-45A4-BFA1-593A2D112BE2 --- .../CrowTerminal/Resources/crow-tmux.conf | 2 +- .../Sources/CrowTerminal/TmuxBackend.swift | 26 ++++++----- .../CrowTerminalTests/TmuxBackendTests.swift | 43 +++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf b/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf index ebd5d3e..fbf06f9 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf +++ b/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf @@ -82,7 +82,7 @@ bind -T root TripleClick1Pane select-pane -t = \; if -F -t = "#{mouse_any_flag}" # and pairs with the send-path copy-mode cancel in TmuxBackend.sendText # (#486) — together they minimize the time a pane sits stuck in copy-mode # after the user scrolls. -bind -T root WheelUpPane if-shell -F -t = "#{mouse_any_flag}" "send-keys -M" { if-shell -F -t = "#{pane_in_mode}" "send-keys -M" "copy-mode -et=" } +bind -T root WheelUpPane if -F -t = "#{mouse_any_flag}" "send-keys -M" { if -F -t = "#{pane_in_mode}" "send-keys -M" "copy-mode -et=" } # Default history-limit is 2000 lines per pane. Phase 3 §2 (the spike) # measured 5 windows × 10k lines as a 400kB RSS delta, so we have plenty diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift index c410c02..881b380 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift @@ -320,12 +320,14 @@ public final class TmuxBackend { /// race: the Enter arrives before the TUI finishes handling the paste, /// causing it to be silently dropped (#272). /// - /// We also pre-cancel copy-mode on the pane before pasting (#486). The - /// bundled `crow-tmux.conf` keeps `mouse on` so wheel scrollback works - /// (#452), but the default `WheelUpPane` puts the pane into copy-mode, - /// where `paste-buffer` does not deliver content. Without the cancel, - /// every programmatic send into a pane the user has scrolled (Manager - /// paste, auto-respond, quick actions) is silently dropped. + /// We also pre-cancel copy-mode on the pane before any delivery (#486). + /// The bundled `crow-tmux.conf` keeps `mouse on` so wheel scrollback + /// works (#452), but the default `WheelUpPane` puts the pane into + /// copy-mode, where both `paste-buffer` and `send-keys Enter` are + /// silently consumed by copy-mode key bindings instead of reaching the + /// underlying shell. Without the cancel, every programmatic send into + /// a pane the user has scrolled (Manager paste, auto-respond, quick + /// actions, bare-Enter submits) is dropped. public func sendText(id: UUID, text: String) throws { guard let windowIndex = bindings[id] else { throw TmuxBackendError.unknownTerminal(id) @@ -336,15 +338,19 @@ public final class TmuxBackend { let endsWithNewline = text.hasSuffix("\n") let payload = endsWithNewline ? String(text.dropLast()) : text + // Cancel copy-mode if the user scrolled the pane into it before + // we deliver anything. Covers both the paste-buffer path (which + // is a no-op in copy-mode) and the bare-Enter path (where + // `send-keys Enter` would otherwise hit the copy-mode key table + // — default emacs `copy-selection-and-cancel`, vi `cancel` — + // exiting copy-mode without delivering a CR to the shell (#486). + try ctrl.cancelCopyModeIfActive(target: target) + var didPaste = false if !payload.isEmpty { let bufferName = "crow-\(id.uuidString)" try ctrl.loadBufferFromStdin(name: bufferName, data: Data(payload.utf8)) defer { ctrl.deleteBuffer(name: bufferName) } - // Cancel copy-mode if the user scrolled the pane into it - // — paste-buffer is a no-op while the pane is in a mode - // (#486). - try ctrl.cancelCopyModeIfActive(target: target) try ctrl.pasteBuffer(name: bufferName, target: target) didPaste = true } diff --git a/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift b/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift index dd11553..2392a53 100644 --- a/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift +++ b/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift @@ -286,6 +286,49 @@ struct TmuxBackendTests { try backend.sendText(id: id, text: "PROD2-486-normal-\(UUID().uuidString)\n") } + /// Regression for the bare-Enter leg of #486: `crow send "\n"` (and the + /// `sendTextEmptyWithNewlineDoesNotThrow` shape) skip the paste path + /// entirely and go straight to `send-keys Enter`. Without the pre-cancel + /// hoisted out of the `if !payload.isEmpty` block, the Enter is routed + /// through the copy-mode key table (emacs `copy-selection-and-cancel`, + /// vi `cancel`) — exits copy-mode but never delivers a CR to the shell. + /// Verify the cancel still runs and the pane leaves copy-mode. + @Test func sendTextBareEnterCancelsCopyMode() throws { + let backend = makeBackend() + defer { backend.shutdown() } + + let id = UUID() + let binding = try backend.registerTerminal( + id: id, + name: "bare-enter-copy-mode", + cwd: NSHomeDirectory(), + command: nil, + trackReadiness: false + ) + + let ctrl = TmuxController( + tmuxBinary: discoveredTmuxBinary!, + socketPath: backend.socketPath, + sessionName: TmuxBackend.cockpitSessionName + ) + let target = "\(TmuxBackend.cockpitSessionName):\(binding.windowIndex)" + _ = try ctrl.run(["copy-mode", "-H", "-t", target]) + + let inModeBefore = try ctrl.run([ + "display-message", "-p", "-t", target, "-F", "#{pane_in_mode}" + ]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(inModeBefore == "1") + + // Bare "\n" — empty payload, didPaste stays false. The pre-cancel + // must still run. + try backend.sendText(id: id, text: "\n") + + let inModeAfter = try ctrl.run([ + "display-message", "-p", "-t", target, "-F", "#{pane_in_mode}" + ]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(inModeAfter == "0") + } + @Test func retryReadinessEmitsTimedOutWhenSentinelMissing() async throws { let backend = makeBackend() defer { backend.shutdown() }