Skip to content
Open
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
1 change: 1 addition & 0 deletions CLI/CMUXCLI+CommandSuggestions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ extension CMUXCLI {
"popup",
"previous-window",
"read-screen",
"read-selection",
"refresh-surfaces",
"reload-config",
"remote-daemon-status",
Expand Down
217 changes: 217 additions & 0 deletions CLI/CMUXCLI+SurfaceSelection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import Foundation

extension CMUXCLI {
func runSurfaceSelectionCommand(
commandName: String,
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
windowOverride: String?,
includeContextInPlainOutput: Bool
) throws {
let (workspaceOption, remainingAfterWorkspace) = parseOption(
commandArgs,
name: "--workspace"
)
let (surfaceOption, remainingAfterSurface) = parseOption(
remainingAfterWorkspace,
name: "--surface"
)
let (windowOption, trailing) = parseOption(
remainingAfterSurface,
name: "--window"
)
guard trailing.isEmpty else {
throw CLIError(message: String(
format: String(
localized: "cli.readSelection.error.unexpectedArguments",
defaultValue: "%@: unexpected arguments: %@"
),
commandName,
trailing.joined(separator: " ")
))
}

let windowRaw = windowOption ?? windowOverride
let workspaceRaw = workspaceOption
?? Self.callerWorkspaceForSurfaceHandle(surfaceOption, windowRaw: windowRaw)
let surfaceRaw = surfaceOption
?? (workspaceOption == nil && windowRaw == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil)

var params: [String: Any] = [:]
let windowID = try normalizeWindowHandle(windowRaw, client: client)
if let windowID {
params["window_id"] = windowID
}
let workspaceID = try normalizeWorkspaceHandle(
workspaceRaw,
client: client,
windowHandle: windowID
)
if let workspaceID {
params["workspace_id"] = workspaceID
}
let surfaceID = try normalizeSurfaceHandle(
surfaceRaw,
client: client,
workspaceHandle: workspaceID,
windowHandle: windowID
)
if let surfaceID {
params["surface_id"] = surfaceID
}

let payload = try client.sendV2(
method: "surface.read_selection",
params: params
)
if jsonOutput {
print(jsonString(payload))
return
}
guard (payload["has_selection"] as? Bool) == true else {
if includeContextInPlainOutput {
let metadata = surfaceSelectionMetadataLines(payload)
if !metadata.isEmpty {
print(metadata.joined(separator: "\n"))
print("")
}
}
print(String(
localized: "cli.readSelection.output.noActiveSelection",
defaultValue: "Has selection: false"
))
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let text = (payload["text"] as? String) ?? ""
guard includeContextInPlainOutput else {
print(text)
return
}

let metadata = surfaceSelectionMetadataLines(payload)
if !metadata.isEmpty {
print(metadata.joined(separator: "\n"))
print("")
}
print(text)
}

private func surfaceSelectionMetadataLines(
_ payload: [String: Any]
) -> [String] {
var lines: [String] = []
if let kind = payload["kind"] as? String, !kind.isEmpty {
lines.append(String(
format: String(
localized: "cli.readSelection.output.kind",
defaultValue: "Kind: %@"
),
kind
))
}
if let filePath = payload["file_path"] as? String, !filePath.isEmpty {
lines.append(String(
format: String(
localized: "cli.readSelection.output.file",
defaultValue: "File: %@"
),
filePath
))
}
if let range = payload["line_range"] as? [String: Any],
let start = surfaceSelectionLineNumber(range["start"]),
let end = surfaceSelectionLineNumber(range["end"]) {
if start == end {
lines.append(String(
format: String(
localized: "cli.readSelection.output.line",
defaultValue: "Line: %lld"
),
Int64(start)
))
} else {
lines.append(String(
format: String(
localized: "cli.readSelection.output.lines",
defaultValue: "Lines: %lld-%lld"
),
Int64(start),
Int64(end)
))
}
}
if let url = payload["url"] as? String, !url.isEmpty {
lines.append(String(
format: String(
localized: "cli.readSelection.output.url",
defaultValue: "URL: %@"
),
url
))
}
return lines
}

private func surfaceSelectionLineNumber(_ value: Any?) -> Int? {
if let value = value as? Int {
return value
}
return (value as? NSNumber)?.intValue
}

static var readSelectionHelp: String {
String(localized: "cli.help.readSelection", defaultValue: """
Usage: cmux read-selection [flags]

Read the active selection from any selectable surface. Plain output includes source context; --json returns the complete response.

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 read-selection --surface surface:2
cmux read-selection --surface surface:2 --json
""")
}

static var readScreenHelp: String {
String(localized: "cli.help.readScreen", defaultValue: """
Usage: cmux read-screen [flags]

Read terminal text from a surface as plain 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
--scrollback Include scrollback (not just visible viewport)
--lines <n> Limit to the last n lines (implies --scrollback)
--selection Read only the active selection; cannot be combined with --scrollback or --lines

Example:
cmux read-screen
cmux read-screen --surface surface:2 --scrollback --lines 200
cmux read-screen --surface surface:2 --selection
""")
}

static var readSelectionUsageLine: String {
String(
localized: "cli.usage.readSelection",
defaultValue: "read-selection [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
}

static var readScreenUsageLine: String {
String(
localized: "cli.usage.readScreen",
defaultValue: "read-screen [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--scrollback] [--lines <n>] [--selection]"
)
}
}
58 changes: 41 additions & 17 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3410,6 +3410,9 @@ struct CMUXCLI {
if normalizedCommand == "restore" {
return false
}
if normalizedCommand == "read-screen" || normalizedCommand == "read-selection" {
return false
}
if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" {
return false
}
Expand Down Expand Up @@ -5259,7 +5262,40 @@ struct CMUXCLI {
print(handle)
}

case "read-selection":
try runSurfaceSelectionCommand(
commandName: "read-selection",
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
windowOverride: windowId,
includeContextInPlainOutput: true
)

case "read-screen":
let selectionOnly = commandArgs.contains("--selection")
if selectionOnly {
let hasScrollback = commandArgs.contains("--scrollback")
let hasLines = commandArgs.contains {
$0 == "--lines" || $0.hasPrefix("--lines=")
}
if hasScrollback || hasLines {
throw CLIError(message: String(
localized: "cli.readSelection.error.readScreenConflict",
defaultValue: "read-screen: --selection cannot be combined with --scrollback or --lines"
))
}
try runSurfaceSelectionCommand(
commandName: "read-screen",
commandArgs: commandArgs.filter { $0 != "--selection" },
client: client,
jsonOutput: jsonOutput,
windowOverride: windowId,
includeContextInPlainOutput: false
)
break
}

let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let (windowOpt, rem2) = parseOption(rem1, name: "--window")
Expand Down Expand Up @@ -17160,23 +17196,10 @@ struct CMUXCLI {
Flags:
-p, --print Print to stdout only
"""
case "read-selection":
return Self.readSelectionHelp
case "read-screen":
return """
Usage: cmux read-screen [flags]

Read terminal text from a surface as plain 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
--scrollback Include scrollback (not just visible viewport)
--lines <n> Limit to the last n lines (implies --scrollback)

Example:
cmux read-screen
cmux read-screen --surface surface:2 --scrollback --lines 200
"""
return Self.readScreenHelp
case "send":
return """
Usage: cmux send [flags] [--] <text>
Expand Down Expand Up @@ -36640,7 +36663,8 @@ export default CMUXSessionRestore;
rename-workspace [--workspace <id|ref|index>] [--window <id|ref|index>] <title>
rename-window [--workspace <id|ref|index>] [--window <id|ref|index>] <title>
current-workspace [--window <id|ref|index>]
read-screen [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--scrollback] [--lines <n>]
\(Self.readSelectionUsageLine)
\(Self.readScreenUsageLine)
send [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <text>
send-key [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <key>
send-panel --panel <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>] <text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
// never runs inline on the main thread, and no in-process main-thread
// caller needs it.
"surface.read_text",
// Selection providers own AppKit/WebKit state on the main actor, then
// return one immutable snapshot for response shaping on this worker.
// The async bridge must never be entered inline by a main-thread caller.
"surface.read_selection",
// `workspace.env` is a read that resolves a workspace and copies its
// env dictionary behind a `v2MainSync` hop, so it runs on the worker
// lane like the other workspace reads below.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ struct ControlCommandExecutionPolicyTests {
// that formatting inline on the main thread, which is exactly the
// stall the lane move removes, and no in-process caller needs it.
#expect(ControlCommandExecutionPolicy(forMethod: "surface.read_text") == .socketWorker(mainThreadCallable: false))
#expect(ControlCommandExecutionPolicy(forMethod: "surface.read_selection") == .socketWorker(mainThreadCallable: false))
#expect(ControlCommandExecutionPolicy(forV1Command: "read_screen") == .socketWorker(mainThreadCallable: false))
}

Expand Down
Loading
Loading