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
22 changes: 18 additions & 4 deletions Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,16 @@ public final class TmuxBackend {
/// Quirk: Claude Code's TUI enables bracketed-paste mode, which wraps
/// `paste-buffer` output in `\e[200~…\e[201~`. A trailing `\n` inside the
/// bracket is treated as literal text, not as Enter — so prompts that
/// rely on `\n` to submit (quick actions: Merge PR, Fix Conflicts, …) get
/// pasted but never submitted (#264). Strip the trailing newline before
/// pasting and deliver a separate `Enter` via `send-keys` afterwards,
/// mirroring what `GhosttySurfaceView.writeText` does with keycode 36.
/// rely on `\n` to submit (quick actions, auto-respond) get pasted but
/// never submitted (#264). Strip the trailing newline before pasting and
/// deliver a separate `Enter` via `send-keys` afterwards, mirroring what
/// `GhosttySurfaceView.writeText` does with keycode 36.
///
/// A 50ms delay between the paste and the Enter keystroke gives the TUI
/// time to process the bracket-end sequence (`\e[201~`). Without this,
/// 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).
public func sendText(id: UUID, text: String) throws {
guard let windowIndex = bindings[id] else {
throw TmuxBackendError.unknownTerminal(id)
Expand All @@ -259,13 +265,21 @@ public final class TmuxBackend {
let endsWithNewline = text.hasSuffix("\n")
let payload = endsWithNewline ? String(text.dropLast()) : text

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) }
try ctrl.pasteBuffer(name: bufferName, target: target)
didPaste = true
}
if endsWithNewline {
// Give the TUI time to process the paste bracket-end before
// the Enter key arrives. Only needed when we actually pasted
// content — a bare "\n" (Enter-only) needs no delay.
if didPaste {
Thread.sleep(forTimeInterval: 0.05)
}
try ctrl.sendKeys(target: target, keys: ["Enter"])
}
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ struct TmuxBackendTests {
#expect(binding.windowIndex >= 0)
}

@Test func sendTextWithTrailingNewlineDoesNotThrow() throws {
let backend = makeBackend()
defer { backend.shutdown() }

let id = UUID()
_ = try backend.registerTerminal(
id: id,
name: "newline-test",
cwd: NSHomeDirectory(),
command: nil,
trackReadiness: false
)

// Trailing \n triggers the paste + separate Enter path (#264/#272).
// Verify the full code path executes without throwing.
let payload = "AUTO-RESPOND-TEST-\(UUID().uuidString)\n"
try backend.sendText(id: id, text: payload)
}

@Test func sendTextEmptyWithNewlineDoesNotThrow() throws {
let backend = makeBackend()
defer { backend.shutdown() }

let id = UUID()
_ = try backend.registerTerminal(
id: id,
name: "bare-enter",
cwd: NSHomeDirectory(),
command: nil,
trackReadiness: false
)

// Edge case: bare "\n" should only send Enter (no paste).
try backend.sendText(id: id, text: "\n")
}

@Test func retryReadinessEmitsTimedOutWhenSentinelMissing() async throws {
let backend = makeBackend()
defer { backend.shutdown() }
Expand Down
Loading