Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
212 changes: 212 additions & 0 deletions CLI/CMUXCLI+SurfaceSelection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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 {
throw CLIError(message: String(
format: String(
localized: "cli.readSelection.error.noActiveSelection",
defaultValue: "%@: no active selection"
),
commandName
))
}
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 @@ -17157,23 +17193,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 @@ -36637,7 +36660,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