Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)"
Expand Down
15 changes: 15 additions & 0 deletions Packages/CrowTerminal/Sources/CrowTerminal/TmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ public struct TmuxController: Sendable {
_ = try? run(["delete-buffer", "-b", name])
}

/// `tmux if-shell -F -t <target> '#{pane_in_mode}' 'send-keys -t <target> -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 <target> -S -<linesBack>`. Returns the
Expand Down
104 changes: 104 additions & 0 deletions Packages/CrowTerminal/Tests/CrowTerminalTests/TmuxBackendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Loading