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
106 changes: 98 additions & 8 deletions CLI/CMUXCLI+Paste.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,48 @@ extension CMUXCLI {
guard !text.isEmpty else {
throw CLIError(message: Self.pasteMissingTextMessage)
}
try Self.ensureTextFitsSocketRequest(text, command: "paste")
try deliverTerminalPaste(
text: text,
command: "paste",
workspace: parsed.workspace,
surface: parsed.surface,
windowRaw: parsed.window ?? windowOverride,
submit: parsed.submit,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat
)
}

/// Sends `text` unchanged through `terminal.paste` and prints the result.
/// Shared by `cmux paste` and `cmux send --paste`, so both resolve their
/// targets and report delivery the same way. `command` names the caller
/// in the size-limit error.
func deliverTerminalPaste(
text: String,
command: String,
workspace: String?,
surface: String?,
windowRaw: String?,
submit: Bool,
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
try Self.ensureTextFitsSocketRequest(text, command: command)

let windowRaw = parsed.window ?? windowOverride
let workspaceArg = parsed.workspace
?? Self.callerWorkspaceForSurfaceHandle(parsed.surface, windowRaw: windowRaw)
let surfaceArg = parsed.surface
?? (parsed.workspace == nil && windowRaw == nil
let workspaceArg = workspace
?? Self.callerWorkspaceForSurfaceHandle(surface, windowRaw: windowRaw)
let surfaceArg = surface
?? (workspace == nil && windowRaw == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil)

var params: [String: Any] = [
"text": text,
// `return` lets the host pick the agent-aware submit key (for
// example ctrl+enter for a multi-line Claude Code prompt).
"submit_key": parsed.submit ? "return" : "none",
"submit_key": submit ? "return" : "none",
]
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
Expand All @@ -115,7 +142,7 @@ extension CMUXCLI {
if let sfId { params["surface_id"] = sfId }

let payload = try client.sendV2(method: "terminal.paste", params: params)
if parsed.submit, (payload["submitted"] as? Bool) != true {
if submit, (payload["submitted"] as? Bool) != true {
// The text is already at the prompt, so this is a warning rather
// than a failure: a caller that retried would paste it twice.
let reason = (payload["submit_error"] as? String) ?? "unknown"
Expand Down Expand Up @@ -246,6 +273,69 @@ extension CMUXCLI {
))
}

// MARK: - cmux send --paste

/// Removes the leading `--paste` flags from `cmux send`'s text arguments.
/// Only flags before the text count, so `cmux send echo --paste` and
/// `cmux send -- --paste` still type `--paste` as text, as before.
static func splitSendPasteFlag(_ args: [String]) -> (usesPaste: Bool, textArgs: [String]) {
var rest = args[...]
var usesPaste = false
while rest.first == "--paste" {
usesPaste = true
rest = rest.dropFirst()
}
return (usesPaste, Array(rest))
}

/// `cmux send` without `--paste` suggests the paste path when the text is
/// more than this many UTF-8 bytes and has a line break. Both conditions
/// are required so ordinary commands (short, or one long line) never see
/// the hint.
static let sendPasteHintMinimumUTF8Bytes = 4096

/// Whether keystroke `send` text is large multi-line text: more than
/// ``sendPasteHintMinimumUTF8Bytes`` UTF-8 bytes with at least one line
/// break (a newline, or a `\n`/`\r` escape, both of which become Enter).
static func sendTextWarrantsPasteHint(_ text: String) -> Bool {
guard text.utf8.count > sendPasteHintMinimumUTF8Bytes else { return false }
return text.unicodeScalars.contains { $0 == "\n" || $0 == "\r" }
}

/// Prints a one-line hint on stderr after a keystroke `send` of large
/// multi-line text. Stdout and the exit status are unaffected.
static func printSendPasteHintIfNeeded(_ text: String) {
guard sendTextWarrantsPasteHint(text) else { return }
let hint = String(
localized: "cli.send.hint.usePaste",
defaultValue: "hint: each newline was typed as Enter; for large multi-line text use cmux send --paste or cmux paste to deliver it as one paste"
)
FileHandle.standardError.write(Data((hint + "\n").utf8))
}

static var sendHelp: String {
String(localized: "cli.help.send", defaultValue: """
Usage: cmux send [flags] [--] <text>

Send text to a terminal surface as keystrokes. Escape sequences: \\n and \\r send Enter, \\t sends Tab.

For large text (for example over 4 KB, or several lines going to an agent), use --paste or cmux paste. Keystrokes press Enter at every newline, which can submit each line on its own.

With --paste, the text goes through the same paste path as cmux paste and Cmd+V, unchanged: escape sequences such as \\n are not interpreted, and control characters such as Esc and Ctrl-C are replaced with spaces. --paste must come before the text.

Flags:
--workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Target surface (default: $CMUX_SURFACE_ID)
--window <id|ref|index> Window context for workspace/surface refs and indexes
--paste Paste the text instead of typing it

Example:
cmux send "echo hello"
cmux send --surface surface:2 "ls -la\\n"
cmux send --paste --surface surface:2 "$(cat notes.md)"
""")
}

static var pasteHelp: String {
String(localized: "cli.help.paste", defaultValue: """
Usage: cmux paste [flags] [--] [text | -]
Expand Down
2 changes: 1 addition & 1 deletion CLI/CMUXCLI+TaskHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ extension CMUXCLI {
automation <list|show|test|enable|disable|logs|reload> [args]
\(executionExchangeHelp)
todo <add|list|check|uncheck|start|rm|clear> [args] [--workspace <id|ref|index>] [--window <id|ref|index>]
send [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <text>
send [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--paste] <text>
send-key [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <key>
paste [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--submit] [text | -]
send-panel --panel <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>] <text>
Expand Down
57 changes: 31 additions & 26 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7515,19 +7515,37 @@ struct CMUXCLI {
let (windowOpt, rem2) = parseOption(rem1, name: "--window")
let windowRaw = windowOpt ?? windowId
let workspaceArg = wsArg ?? Self.callerWorkspaceForSurfaceHandle(sfArg, windowRaw: windowRaw)
let surfaceArg = sfArg ?? (wsArg == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let rawText = rem2.dropFirst(rem2.first == "--" ? 1 : 0).joined(separator: " ")
let (usesPaste, textArgs) = Self.splitSendPasteFlag(rem2)
let rawText = textArgs.dropFirst(textArgs.first == "--" ? 1 : 0).joined(separator: " ")
guard !rawText.isEmpty else { throw CLIError(message: "send requires text") }
let text = unescapeSendText(rawText)
var params: [String: Any] = ["text": text]
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2SendSummary(payload, idFormat: idFormat))
if usesPaste {
// Same path as `cmux paste`: the text goes out unchanged, so
// `\n`-style escapes are not rewritten.
try deliverTerminalPaste(
text: rawText,
command: "send",
workspace: wsArg,
surface: sfArg,
windowRaw: windowRaw,
submit: false,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat
)
} else {
let surfaceArg = sfArg ?? (wsArg == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let text = unescapeSendText(rawText)
var params: [String: Any] = ["text": text]
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2SendSummary(payload, idFormat: idFormat))
Self.printSendPasteHintIfNeeded(text)
}

case "paste":
try runPasteCommand(
Expand Down Expand Up @@ -19990,20 +20008,7 @@ struct CMUXCLI {
case "paste":
return Self.pasteHelp
case "send":
return """
Usage: cmux send [flags] [--] <text>

Send text to a terminal surface. Escape sequences: \\n and \\r send Enter, \\t sends Tab.

Flags:
--workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Target surface (default: $CMUX_SURFACE_ID)
--window <id|ref|index> Window context for workspace/surface refs and indexes

Example:
cmux send "echo hello"
cmux send --surface surface:2 "ls -la\\n"
"""
return Self.sendHelp
case "send-key":
return """
Usage: cmux send-key [flags] [--] <key>
Expand Down
Loading
Loading