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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,7 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_layout_focus_contract.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_browser_profile_cli.py
python3 tests/test_stress_cli_socket_api.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_openagent_plugin_migration.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py
Expand Down
79 changes: 73 additions & 6 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4648,6 +4648,7 @@ struct CMUXCLI {
let type = optionValue(commandArgs, name: "--type")
let direction = optionValue(commandArgs, name: "--direction") ?? "right"
let url = optionValue(commandArgs, name: "--url")
let profile = try parseBrowserProfileOption(commandArgs).selector
let placement = optionValue(commandArgs, name: "--placement")
let focusOpt = optionValue(commandArgs, name: "--focus")
var params: [String: Any] = ["direction": direction]
Expand All @@ -4657,6 +4658,15 @@ struct CMUXCLI {
if let wsId { params["workspace_id"] = wsId }
if let type { params["type"] = type }
if let url { params["url"] = url }
if let profile {
guard type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "browser" else {
throw CLIError(message: String(
localized: "browser.profile.automation.error.profileRequiresBrowserPane",
defaultValue: "Browser profiles can only be used when creating a browser pane"
))
}
params["profile"] = profile
}
if let placement { params["placement"] = placement }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try applyFocusOption(focusOpt, defaultValue: false, to: &params)
let payload = try client.sendV2(method: "pane.create", params: params)
Expand Down Expand Up @@ -13109,7 +13119,10 @@ struct CMUXCLI {
}
var markers: [String] = []
if (profile["current"] as? Bool) == true {
markers.append("current")
markers.append(String(
localized: "cli.browser.profiles.marker.lastUsed",
defaultValue: "last used"
))
}
if (profile["built_in_default"] as? Bool) == true {
markers.append("default")
Expand Down Expand Up @@ -13382,7 +13395,8 @@ struct CMUXCLI {
// Parse routing flags before URL assembly so they never leak into the URL string.
let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace")
let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window")
let (focusOpt, urlArgs) = parseOption(argsAfterWindow, name: "--focus")
let (focusOpt, argsAfterFocus) = parseOption(argsAfterWindow, name: "--focus")
let (profileSelector, urlArgs) = try parseBrowserProfileOption(argsAfterFocus)
// Reject unrecognized flags instead of folding them into the URL, where they
// would silently produce an unparseable URL (blank page) or a search query.
if let strayFlag = urlArgs.first(where: { $0.hasPrefix("--") }) {
Expand All @@ -13403,6 +13417,12 @@ struct CMUXCLI {

if surfaceRaw != nil, subcommand == "open" {
// Treat `browser <surface> open <url>` as navigate for agent-browser ergonomics.
guard profileSelector == nil else {
throw CLIError(message: String(
localized: "cli.browser.profile.error.navigateUnsupported",
defaultValue: "--profile is only supported when browser open creates a new pane; omit the surface selector"
))
}
let sid = try requireSurface()
guard !url.isEmpty else {
throw CLIError(message: "browser <surface> open requires a URL")
Expand All @@ -13419,6 +13439,9 @@ struct CMUXCLI {
if !url.isEmpty {
params["url"] = url
}
if let profileSelector {
params["profile"] = profileSelector
}
if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) {
params["surface_id"] = sourceSurface
}
Expand Down Expand Up @@ -16009,6 +16032,7 @@ struct CMUXCLI {
--workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)
--window <id|ref|index> Window context for workspace refs and indexes
--url <url> URL for browser panes
\(String(localized: "cli.newPane.help.profileDescription", defaultValue: "--profile <name|uuid> Browser profile name or UUID"))
--focus <true|false> Focus the new pane (default: false)

Example:
Expand Down Expand Up @@ -16835,7 +16859,7 @@ struct CMUXCLI {
`open`/`open-split`/`new`/`identify` can run without an explicit surface.

Subcommands:
open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>]
open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile <name|uuid>]"))
open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set
--focus defaults to false
disable | enable | status
Expand Down Expand Up @@ -16991,6 +17015,49 @@ struct CMUXCLI {
return (value, remaining)
}

func parseBrowserProfileOption(_ args: [String]) throws -> (selector: String?, remaining: [String]) {
var remaining: [String] = []
var selector: String?
var index = 0
var pastTerminator = false
while index < args.count {
let arg = args[index]
if pastTerminator || arg == "--" {
pastTerminator = true
remaining.append(arg)
index += 1
continue
}
if arg == "--profile" {
guard index + 1 < args.count, !args[index + 1].hasPrefix("-") else {
throw CLIError(message: String(
localized: "cli.browser.profile.error.emptySelector",
defaultValue: "--profile requires a non-empty profile name or UUID"
))
}
selector = args[index + 1]
index += 2
continue
}
if arg.hasPrefix("--profile=") {
selector = String(arg.dropFirst("--profile=".count))
index += 1
continue
}
remaining.append(arg)
index += 1
}
guard let selector else { return (nil, remaining) }
let normalized = selector.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
throw CLIError(message: String(
localized: "cli.browser.profile.error.emptySelector",
defaultValue: "--profile requires a non-empty profile name or UUID"
))
}
return (normalized, remaining)
}

func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) {
var remaining: [String] = []
var values: [String] = []
Expand Down Expand Up @@ -35224,7 +35291,7 @@ export default CMUXSessionRestore;
top [--all] [--workspace <id|ref|index>] [--window <id|ref|index>] [--processes] [--sort <cpu|mem|proc>] [--flat] [--format <tree|tsv>]
memory [--all] [--workspace <id|ref|index>] [--groups <count>]
focus-pane --pane <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>]
new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] [--focus <true|false>]
new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile <name|uuid>]")) [--focus <true|false>]
new-surface [--type <terminal|browser|agent-session>] [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] [--provider <codex|claude|opencode>] [--renderer <react|solid>] [--focus <true|false>]
close-surface [--surface <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>]
move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>]
Expand Down Expand Up @@ -35299,8 +35366,8 @@ export default CMUXSessionRestore;

browser [--surface <id|ref|index> | <surface>] <subcommand> ...
browser disable | enable | status
browser open [url] [--focus <true|false>] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
browser open-split [url]
browser open [url] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile <name|uuid>]")) [--focus <true|false>] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
browser open-split [url] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile <name|uuid>]"))
browser goto|navigate <url> [--snapshot-after]
browser back|forward|reload [--snapshot-after]
browser react-grab toggle [--surface <id>] [--return-to <terminal-surface>]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ public final class BrowserProfileRepository {
profiles.first(where: { $0.id == id })
}

/// Resolves a profile selector as an existing UUID or display name.
///
/// UUID lookup takes precedence. A UUID-shaped selector whose identifier is
/// not present still falls back to an exact, case-insensitive display-name
/// match.
/// - Parameter rawSelector: A profile UUID or display name.
/// - Returns: The unique match, no match, or every ambiguous name match.
public func resolveProfileSelection(_ rawSelector: String) -> BrowserProfileSelectionResolution {
let selector = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines)
if let id = UUID(uuidString: selector),
let profile = profileDefinition(id: id) {
return .matched(profile)
}

let matches = profiles.filter {
$0.displayName.localizedCaseInsensitiveCompare(selector) == .orderedSame
}
switch matches.count {
case 0:
return .notFound
case 1:
return .matched(matches[0])
default:
return .ambiguous(matches)
}
}

/// Display name for a profile id, falling back to the default profile name.
/// - Parameter id: The profile id.
/// - Returns: The profile's display name, or the default name when unknown.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// The result of resolving a user-supplied browser profile selector.
///
/// Selectors first match an existing profile UUID, then fall back to an exact,
/// case-insensitive display-name match.
public enum BrowserProfileSelectionResolution: Equatable, Sendable {
/// The selector identified exactly one profile.
case matched(BrowserProfileDefinition)
/// No profile UUID or display name matched the selector.
case notFound
/// More than one profile has the requested display name.
case ambiguous([BrowserProfileDefinition])
}
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,44 @@ struct BrowserProfileRepositoryTests {
#expect(defaults.string(forKey: BrowserProfileRepository.lastUsedProfileDefaultsKey) == p.id.uuidString)
}

@Test func profileSelectionResolvesUUIDBeforeDisplayName() {
let (repo, _) = makeRepository()
let defaultID = BrowserProfileRepository.builtInDefaultProfileID
let namedLikeDefaultID = repo.createProfile(named: defaultID.uuidString)!

#expect(repo.resolveProfileSelection(defaultID.uuidString) == .matched(
repo.profileDefinition(id: defaultID)!
))
#expect(namedLikeDefaultID.id != defaultID)
}

@Test func profileSelectionFallsBackFromMissingUUIDToCaseInsensitiveDisplayName() {
let (repo, _) = makeRepository()
let missingID = UUID()
let namedLikeMissingID = repo.createProfile(named: missingID.uuidString)!
let work = repo.createProfile(named: "Work Profile")!

#expect(repo.resolveProfileSelection(missingID.uuidString.lowercased()) == .matched(namedLikeMissingID))
#expect(repo.resolveProfileSelection(" work profile ") == .matched(work))
}

@Test func profileSelectionReportsEveryAmbiguousDisplayNameCandidate() {
let (repo, _) = makeRepository()
let first = repo.createProfile(named: "Shared")!
let second = repo.createProfile(named: "shared")!

guard case .ambiguous(let candidates) = repo.resolveProfileSelection("SHARED") else {
Issue.record("Expected ambiguous profile selection")
return
}
#expect(Set(candidates.map(\.id)) == [first.id, second.id])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@Test func profileSelectionReportsUnknownSelector() {
let (repo, _) = makeRepository()
#expect(repo.resolveProfileSelection("Missing Profile") == .notFound)
}

@Test func effectiveLastUsedFallsBackWhenMissing() {
let (_, defaults) = makeRepository()
// Force lastUsed to a stale id via persistence, then reload.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,19 @@ extension ControlCommandCoordinator {
guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let profileKeys = ["profile", "profile_id", "profile_name"]

let inputs = ControlPaneCreateInputs(
directionRaw: string(params, "direction"),
typeRaw: string(params, "type"),
urlRaw: string(params, "url"),
profileRaw: string(params, "profile")
?? string(params, "profile_id")
?? string(params, "profile_name"),
hasInvalidProfileParam: profileKeys.contains {
hasNonNull(params, $0) && string(params, $0) == nil
},
hasMultipleProfileParams: profileKeys.filter { hasNonNull(params, $0) }.count > 1,
workingDirectory: optionalTrimmedRawString(params, "working_directory"),
initialCommand: optionalTrimmedRawString(params, "initial_command"),
tmuxStartCommand: optionalTrimmedRawString(params, "tmux_start_command"),
Expand Down Expand Up @@ -376,6 +384,21 @@ extension ControlCommandCoordinator {
"placement_strategy": .string("external_browser_disabled"),
"url": .string(url),
]))
case .invalidBrowserProfile(let selector, let message, let candidates):
var data: [String: JSONValue] = ["profile": .string(selector)]
if !candidates.isEmpty {
data["candidates"] = .array(candidates.map { candidate in
.object([
"id": .string(candidate.id.uuidString),
"name": .string(candidate.displayName),
])
})
}
return .err(
code: "invalid_params",
message: message,
data: .object(data)
)
case .workspaceNotFound:
return .err(code: "not_found", message: "Workspace not found", data: nil)
case .noSourceSurface:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
public import Foundation

/// A browser profile candidate included in an ambiguous `pane.create` error.
public struct ControlPaneBrowserProfileCandidate: Sendable, Equatable {
/// The candidate's stable profile identifier.
public let id: UUID
/// The candidate's human-readable display name.
public let displayName: String

/// Creates an ambiguous browser profile candidate.
/// - Parameters:
/// - id: The candidate's stable profile identifier.
/// - displayName: The candidate's human-readable display name.
public init(id: UUID, displayName: String) {
self.id = id
self.displayName = displayName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ public struct ControlPaneCreateInputs: Sendable, Equatable {
/// The raw `url` string, if present (legacy `v2String`), used for the URL
/// and the browser-disabled error data.
public let urlRaw: String?
/// The trimmed browser profile selector from `profile`, `profile_id`, or
/// `profile_name`, if present.
public let profileRaw: String?
/// Whether any non-null browser profile selector had a non-string or empty
/// value and must be rejected instead of treated as an omitted selector.
public let hasInvalidProfileParam: Bool
/// Whether more than one non-null browser profile selector alias was
/// supplied.
public let hasMultipleProfileParams: Bool
/// The trimmed-non-empty `working_directory`, if any (legacy
/// `v2OptionalTrimmedRawString`).
public let workingDirectory: String?
Expand Down Expand Up @@ -50,6 +59,9 @@ public struct ControlPaneCreateInputs: Sendable, Equatable {
/// - directionRaw: The trimmed `direction` string, if present.
/// - typeRaw: The trimmed `type` string, if present.
/// - urlRaw: The raw `url` string, if present.
/// - profileRaw: The browser profile UUID or display name, if present.
/// - hasInvalidProfileParam: Whether a supplied selector was malformed.
/// - hasMultipleProfileParams: Whether multiple selector aliases were supplied.
/// - workingDirectory: The trimmed-non-empty working directory, if any.
/// - initialCommand: The trimmed-non-empty initial command, if any.
/// - tmuxStartCommand: The trimmed-non-empty tmux start command, if any.
Expand All @@ -63,6 +75,9 @@ public struct ControlPaneCreateInputs: Sendable, Equatable {
directionRaw: String?,
typeRaw: String?,
urlRaw: String?,
profileRaw: String? = nil,
hasInvalidProfileParam: Bool = false,
hasMultipleProfileParams: Bool = false,
workingDirectory: String?,
initialCommand: String?,
tmuxStartCommand: String?,
Expand All @@ -76,6 +91,9 @@ public struct ControlPaneCreateInputs: Sendable, Equatable {
self.directionRaw = directionRaw
self.typeRaw = typeRaw
self.urlRaw = urlRaw
self.profileRaw = profileRaw
self.hasInvalidProfileParam = hasInvalidProfileParam
self.hasMultipleProfileParams = hasMultipleProfileParams
self.workingDirectory = workingDirectory
self.initialCommand = initialCommand
self.tmuxStartCommand = tmuxStartCommand
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public enum ControlPaneCreateResolution: Sendable, Equatable {
/// URL opened externally (legacy `ok`, the external-open payload). Carries
/// the resolved window (may be absent) and the opened URL string.
case browserDisabledOpenedExternally(windowID: UUID?, url: String)
/// An explicit browser profile selector did not identify exactly one
/// profile. An empty candidate list means no profile matched; otherwise the
/// candidates share the requested display name.
case invalidBrowserProfile(
selector: String,
message: String,
candidates: [ControlPaneBrowserProfileCandidate]
)
/// A TabManager resolved but no workspace did (legacy `not_found` /
/// "Workspace not found", `data: nil`).
case workspaceNotFound
Expand Down
Loading