diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf b/Packages/CrowTerminal/Sources/CrowTerminal/Resources/crow-tmux.conf index 3ad2ee3..fbf06f9 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 -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 # 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..881b380 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift @@ -319,6 +319,15 @@ 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 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) @@ -329,6 +338,14 @@ 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)" 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..2392a53 100644 --- a/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift +++ b/Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift @@ -225,6 +225,110 @@ 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") + } + + /// 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() }