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
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,45 @@ public nonisolated enum ActionSurfaceExport {
row["default_chord"] = descriptor.defaultChord.map { [wireShortcut($0.first), wireShortcut($0.second)] as Any } ?? NSNull()
row["default_aliases"] = defaultAliases(for: descriptor.id)
row["palette_suggested"] = descriptor.paletteSuggestionRank.map { $0 as Any } ?? NSNull()
row["args"] = descriptor.arguments.map(wireArgument)
return row
}

/// One argument as `action.run` takes it: its canonical name (lowerCamelCase, what the CLI's
/// `--flag-name` becomes), its kind, whether it is required, an enumeration's values and a target's
/// kind. The Rust CLI's argument contract test (cmux-tui `cli/tests/action_argument_contract.rs`)
/// checks every call site against these, so a hand-spelled name that drifts fails on Linux.
public static func wireArgument(_ argument: ActionArgument) -> [String: Any] {
var wire: [String: Any] = ["name": argument.name, "required": argument.isRequired]
switch argument.kind {
case .string: wire["kind"] = "string"
case .int: wire["kind"] = "int"
case .bool: wire["kind"] = "bool"
case .enumeration(let cases):
wire["kind"] = "enum"
wire["values"] = cases.map(\.value)
case .target(let kind):
wire["kind"] = "target"
wire["target_kind"] = kind.rawValue
}
return wire
}

/// Whether `name` is an argument's canonical spelling: lowerCamelCase, no `-` or `_`
/// (`promptId`, never `prompt-id` or `prompt_id`). The CLI turns `--prompt-id` into `promptId`, so any
/// other spelling is out of its reach.
public static func isCanonicalArgumentName(_ name: String) -> Bool {
guard let first = name.unicodeScalars.first, ("a"..."z").contains(first) else { return false }
return name.unicodeScalars.allSatisfy { ("a"..."z").contains($0) || ("A"..."Z").contains($0) || ("0"..."9").contains($0) }
}

/// An argument name with case, `-` and `_` folded away: `prompt-id`, `prompt_id` and `promptId` are
/// one name. The control router takes any spelling that folds to an argument's name, so a caller still
/// on an old spelling keeps working for a release; the contract tests keep new code on the canonical one.
public static func foldedArgumentName(_ name: String) -> String {
String(name.lowercased().filter { $0 != "-" && $0 != "_" })
}

/// The binding table's default entries for `id` beyond its catalog key
/// (`KeyBindingDefaults`: Cmd-Shift-] / [ for pane tabs, Ctrl-Cmd arrows for pane
/// resize, Ctrl-Cmd H/J/K/L for pane focus, list navigation, the palette keys, scoped defaults such
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ nonisolated enum HistoryActionCatalog: ActionCatalogGroup {
symbol: "clock.arrow.circlepath", surfaces: [.palette, .keyboard],
arguments: [
ActionArgument(name: "id", title: t("argument.history.entry", "History Entry ID"), kind: .string, isRequired: true),
ActionArgument(name: "new_tab", title: t("argument.history.newTab", "In New Tab"), kind: .bool, isRequired: false),
ActionArgument(name: "newTab", title: t("argument.history.newTab", "In New Tab"), kind: .bool, isRequired: false),
],
cliName: "history open"
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,14 @@ nonisolated enum SettingsActionCatalog: ActionCatalogGroup {
title: String(localized: "action.palette.installCLI", defaultValue: "Install cmux CLI in PATH", bundle: .module),
keywords: ["command line", "shell"], category: .settings, symbol: "terminal", surfaces: [.palette],
// Another app's `cmux` at /usr/local/bin is never replaced
// silently: `--replace` replaces it, `--cmux_next` installs
// silently: `--replace` replaces it, `--cmux-next` installs
// /usr/local/bin/cmux-next beside it; without either the app
// installs cmux-next beside it and its toast offers Replace.
arguments: [
ActionArgument(name: "replace",
title: String(localized: "argument.installCLI.replace", defaultValue: "Replace Another App's cmux", bundle: .module),
kind: .bool, isRequired: false),
ActionArgument(name: "cmux_next",
ActionArgument(name: "cmuxNext",
title: String(localized: "argument.installCLI.cmuxNext", defaultValue: "Install as cmux-next", bundle: .module),
kind: .bool, isRequired: false),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ extension AgentTabStore {
guard let tab = try? await pending.value(), let self, await TabMirror.reaches(tab.key, in: locate) else { finished?(false); return }; created?(tab.key)
// An unfocused (script) tab open, a subagent's in the right column's lower pane, leaves focus where it is.
guard split else { if origin == .user { _ = actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), origin: origin)) }; finished?(false); return }
_ = actionRegistry?.perform("tab.moveToNewSplit", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), arguments: ["direction": .string(direction), "keep_focus": .bool(origin == .script)], origin: origin)) // script: an unfocused subagent open
_ = actionRegistry?.perform("tab.moveToNewSplit", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), arguments: ["direction": .string(direction), "keepFocus": .bool(origin == .script)], origin: origin)) // script: an unfocused subagent open
if let finished { finished(await TabMirror.reaches(tab.key, in: { [weak self] in self?.locate($0).flatMap { $0.pane != pane ? $0 : nil } })) } // stacking waits for the move
}
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ enum CLIInstallHandlers {
static func bind(into registry: ActionRegistry, context: AppActionContext) {
registry.bind("palette.installCLI", run: { invocation in
let window = context.activeWindow?.window
let name: CLIPathInstaller.Name = invocation["cmux_next"]?.boolValue == true ? .cmuxNext : .cmux
let name: CLIPathInstaller.Name = invocation["cmuxNext"]?.boolValue == true ? .cmuxNext : .cmux
let replacing = invocation["replace"]?.boolValue == true
Task { @MainActor in // task-owner: one install, ended by its toast
await install(name, replacing: replacing, in: window)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ enum HistoryHandlers {
// entry is looked up from its owners, so a caller names it and never supplies its facts.
registry.bind("history.open", run: { invocation in
guard let id = invocation["id"]?.stringValue, !id.isEmpty else { return }
let newTab = invocation["new_tab"]?.boolValue ?? false
let newTab = invocation["newTab"]?.boolValue ?? false
services.registry.track(Task { @MainActor in
guard let entry = await services.history.entry(id: id), entry.isAvailable else {
return ActionWorkFailure(HistoryAppStrings.entryGone)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ extension TabHandlers {
case "down": .bottom
default: .right
}
let keepFocus = invocation["keep_focus"]?.boolValue == true
let keepFocus = invocation["keepFocus"]?.boolValue == true
// The chat dock never splits (ChatDockRules): a chat moved out of it to its right, when the
// dock keeps another tab, gets a new column right after the dock, so a docked chat's
// subagents open beside it. A lone docked chat is still refused (the dock would close).
Expand All @@ -44,7 +44,7 @@ extension TabHandlers {
registry.bind("tab.moveToNewColumn", invoke: { invocation in
guard let (tab, pane) = ctx.daemonTab(invocation) else { return }
let reveal = revealer(ctx, tab: tab, outcome: .newColumn(screenID: "", afterColumnID: ""), workspaceID: nil)
TabMoves.toNewColumn(tab, anchor: pane, services: ctx.services, keepFocus: invocation["keep_focus"]?.boolValue == true, completion: reveal)
TabMoves.toNewColumn(tab, anchor: pane, services: ctx.services, keepFocus: invocation["keepFocus"]?.boolValue == true, completion: reveal)
})
registry.bind("tab.moveToWorkspace", invoke: { invocation in
guard let (tab, _) = ctx.daemonTab(invocation), let workspace = ctx.workspaceArgument(invocation) else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ extension ControlRouter {
default: throw ControlError.invalidParams(ControlStrings.text("control.error.argsShape", "args must be an object of name: value"))
}
let schema = Dictionary(action.arguments.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
let folded = Dictionary(action.arguments.map { (Self.foldedArgumentName($0.name), $0.name) }, uniquingKeysWith: { first, _ in first })
for (given, raw) in rawArguments {
// Rooms became Spaces: `--room` still names the `space` argument.
let name = schema[given] == nil ? Self.renamedArguments[given].flatMap { schema[$0] == nil ? nil : $0 } ?? given : given
// Rooms became Spaces: `--room` still names the `space` argument. A spelling the schema does not use
// (`prompt-id`, `prompt_id` or `promptId` for one named another way) names the argument it folds to:
// the CLI sends `--prompt-id` as `promptId`, and one drifted name failed `cmux cloud start-chat`.
let name = schema[given] != nil ? given
: Self.renamedArguments[given].flatMap { schema[$0] == nil ? nil : $0 } ?? folded[Self.foldedArgumentName(given)] ?? given
guard let argument = schema[name] else {
throw ControlError.invalidParams(
ControlStrings.format("control.error.noSuchArgument", "%1$@ has no argument '%2$@'", action.id, name),
Expand All @@ -87,6 +91,11 @@ extension ControlRouter {
/// Old argument names and the ones that replaced them (data-model.md 3.4).
static let renamedArguments = ["room": "space", "machine": "host"]

/// An argument name with case, `-` and `_` folded away: `prompt-id`, `prompt_id` and `promptId` are one name.
static func foldedArgumentName(_ name: String) -> String {
String(name.lowercased().filter { $0 != "-" && $0 != "_" })
}

static func value(_ raw: JSONValue, for argument: ControlArgumentInfo, action: String, knownKinds: [String]) throws -> ControlValue {
func fail(_ expected: String) -> ControlError {
.invalidParams(ControlStrings.format("control.error.argumentExpects", "%1$@ argument '%2$@' expects %3$@", action, argument.name, expected), data: ["argument": .string(argument.name)])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import CmuxNextActions
@testable import CmuxNextControl
import CmuxNextSettings
import Foundation
import Testing

/// The action argument contract between the CLI and the app. The CLI turns every `--flag-name` into
/// `flagName` (cmux-tui `cli/app.rs` `argument_name`), and the catalog is exported with each action's
/// arguments to `plans/cmux-next/action-surfaces.json`, which the Rust contract test
/// (`cli/tests/action_argument_contract.rs`) checks every CLI call site against. `cloud relay-chat` named
/// its argument `prompt-id` while the CLI sent `promptId`, and every `cmux cloud start-chat` failed
/// `invalid_params` (#36193); `new_tab` and `cmux_next` were out of the CLI's reach the same way.
@MainActor
// Named so check-action-surfaces.sh's `ActionContractTests` filter runs it on every catalog change.
@Suite struct ArgumentActionContractTests {
static func info(_ descriptor: ActionDescriptor) -> ControlActionInfo {
ControlActionInfo(id: descriptor.id.rawValue, title: descriptor.title, category: descriptor.category.rawValue,
categoryTitle: descriptor.category.title, cliName: descriptor.cliName, symbol: descriptor.symbol,
keywords: [], shortcut: nil, shortcutConfig: nil,
arguments: descriptor.arguments.map(RegistryControlBridge.argumentInfo),
targets: descriptor.targets.map(\.rawValue), requiresMask: 0, requires: [], isBound: true,
isDebugOnly: false, mainMenu: nil)
}

static func validated(_ action: ControlActionInfo, _ args: [String: JSONValue]) throws -> ControlActionRequest {
try ControlRouter.validatedRequest(for: action, params: ["args": .object(args)], knownKinds: action.targets,
connection: ControlConnectionID(rawValue: 1))
}

/// Every argument is named in lowerCamelCase, the one spelling the CLI's flags reach.
@Test func everyArgumentNameIsCanonical() {
for descriptor in ActionCatalog.all {
for argument in descriptor.arguments {
#expect(ActionSurfaceExport.isCanonicalArgumentName(argument.name),
"\(descriptor.id.rawValue) argument '\(argument.name)' is not lowerCamelCase")
}
}
}

/// Any spelling of a flag names one argument: no two arguments of an action fold to one name.
@Test func noTwoArgumentsOfAnActionFoldToOneName() {
for descriptor in ActionCatalog.all where !descriptor.arguments.isEmpty {
let names = descriptor.arguments.map(\.name)
let folded = names.map(ControlRouter.foldedArgumentName)
#expect(Set(folded).count == folded.count, "\(descriptor.id.rawValue): \(names)")
#expect(folded == names.map(ActionSurfaceExport.foldedArgumentName), "\(descriptor.id.rawValue)")
}
}

/// The export carries each action's arguments as the router checks them.
@Test func theExportCarriesEveryArgument() throws {
for descriptor in ActionCatalog.all {
let row = ActionSurfaceExport.catalogObject(descriptor, titles: ActionTitleCatalog())
let args = try #require(row["args"] as? [[String: Any]], "\(descriptor.id.rawValue) exports no args")
#expect(args.compactMap { $0["name"] as? String } == descriptor.arguments.map(\.name), "\(descriptor.id.rawValue)")
#expect(args.compactMap { $0["required"] as? Bool } == descriptor.arguments.map(\.isRequired), "\(descriptor.id.rawValue)")
}
}

/// A kebab or snake spelling (an older CLI, a hand-written script) still names the canonical argument
/// for a release, and arrives under the canonical name.
@Test(arguments: ["promptId", "prompt-id", "prompt_id"])
func anySpellingOfAnArgumentNamesIt(_ given: String) throws {
let descriptor = try #require(ActionCatalog.all.first { $0.id.rawValue == "cloudRelayChat" })
let request = try Self.validated(Self.info(descriptor), [given: .string("p-1"), "prompt": .string("hi"), "agent": .string("claude")])
#expect(request.arguments["promptId"] != nil)
#expect(request.arguments.count == 3)
}

/// The renamed snake_case arguments keep their old spelling for a release.
@Test func theRenamedSnakeCaseArgumentsStillTakeTheirOldNames() throws {
let renamed = [("history.open", "new_tab", "newTab"), ("palette.installCLI", "cmux_next", "cmuxNext")]
for (id, old, new) in renamed {
let descriptor = try #require(ActionCatalog.all.first { $0.id.rawValue == id })
var args: [String: JSONValue] = [old: .bool(true)]
if id == "history.open" { args["id"] = .string("entry-1") }
let request = try Self.validated(Self.info(descriptor), args)
#expect(request.arguments[new] != nil, "\(id) took '\(old)' as \(Array(request.arguments.keys))")
#expect(request.arguments[old] == nil, "\(id)")
}
}
}
2 changes: 1 addition & 1 deletion cmux-tui/crates/cmux-tui/src/cli/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ pub(super) fn action_origin() -> &'static str {

/// An action argument's name from its flag: the catalog names arguments in
/// camelCase (`--keep-sessions` is `keepSessions`).
fn argument_name(flag: &str) -> String {
pub(super) fn argument_name(flag: &str) -> String {
let mut name = String::with_capacity(flag.len());
let mut upper = false;
for character in flag.chars() {
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/crates/cmux-tui/src/cli/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use super::*;

// The app fallback and the app scopes exist on unix only (cli.rs).
#[cfg(unix)]
mod action_argument_contract;
#[cfg(unix)]
mod action_surface_parity;
#[cfg(unix)]
mod cli_name_hints;
Expand Down
Loading
Loading