Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3980296
test: cover parameterized workspace layouts
austinywang Jul 14, 2026
c3950a0
feat: parameterize workspace layouts
austinywang Jul 14, 2026
55431fc
docs: describe workspace template parameters
austinywang Jul 14, 2026
d851c7a
Merge remote-tracking branch 'origin/main' into issue-8059-parameteri…
austinywang Jul 14, 2026
b8e7a2a
Address workspace parameter review feedback
austinywang Jul 14, 2026
32a225a
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 15, 2026
dd6d719
Add failing workspace parameter UI contract tests
austinywang Jul 15, 2026
0b6ed04
Prompt for workspace layout parameters in the UI
austinywang Jul 15, 2026
dc3d8eb
Add failing server-side template resolution tests
austinywang Jul 15, 2026
2ff7ee7
Resolve workspace templates once in the app
austinywang Jul 15, 2026
dcc30e1
Align workspace parameter UI with shared action architecture
austinywang Jul 15, 2026
c641dae
Add failing workspace parameter form sizing test
austinywang Jul 15, 2026
c14b918
Size workspace parameter form before presentation
austinywang Jul 15, 2026
882b012
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 15, 2026
7faddef
Add failing workspace command input regressions
austinywang Jul 15, 2026
3e7322b
Preserve workspace command shell input semantics
austinywang Jul 15, 2026
e8fe3b3
Add failing no-prompt workspace parameter test
austinywang Jul 15, 2026
62418c8
Make workspace parameter editing explicit
austinywang Jul 16, 2026
76eb393
Merge remote-tracking branch 'origin/main' into issue-8059-parameteri…
austinywang Jul 16, 2026
341a9e7
Remove stale workspace prompt binding
austinywang Jul 16, 2026
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
148 changes: 148 additions & 0 deletions CLI/CMUXCLI+WorkspaceTemplateParameters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import CmuxFoundation
import Foundation

extension CMUXCLI {
static let workspaceCreateHelpText = String(
localized: "cli.workspace.create.help",
defaultValue: """
Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--env KEY=VALUE]... [--env-file <path>]... [--param KEY=VALUE]... [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]

Create a new workspace in the caller's window.

Flags:
--name <title> Set a custom name for the new workspace
--description <text> Set a custom description for the new workspace
--cwd <path> Set the working directory for the new workspace
--command <text> Send text+Enter to the new workspace after creation
--env KEY=VALUE Set a workspace environment variable. Repeatable.
Reserved CMUX_* variables cannot be overridden.
--env-file <path> Load KEY=VALUE lines from a file. Repeatable.
--param KEY=VALUE Set a {{variable}} used by workspace strings. Repeatable.
--param KEY Import KEY from the invoking shell environment.
--layout <json> Create workspace with a predefined split layout.
Layout surfaces define their own commands.
--window <id|ref|index> Target window (default: caller's window)
--focus <true|false> Focus the new workspace (default: false)
--group <id|ref> Add the new workspace to a workspace group
--group-placement afterCurrent|top|end Placement within --group (default: top)
--group-reference <workspace> Reference workspace for afterCurrent placement

Examples:
cmux new-workspace
cmux new-workspace --name "Build Server"
cmux new-workspace --cwd . --command "npm test"
cmux new-workspace --name "Dev {{ticket}}" --param ticket=BERKS-87
"""
)

struct WorkspaceTemplateParameterOptions {
let values: [String: String]
let remaining: [String]
}

/// Parses repeatable `--param KEY=VALUE`, `--param=KEY=VALUE`, and
/// `--param KEY` arguments. The last value wins; the value-less form imports
/// that key from the invoking CLI process environment.
func parseWorkspaceTemplateParameterOptions(
_ args: [String],
commandName: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) throws -> WorkspaceTemplateParameterOptions {
var values: [String: String] = [:]
var remaining: [String] = []
var index = 0
var pastTerminator = false

while index < args.count {
let argument = args[index]
if argument == "--" {
pastTerminator = true
remaining.append(argument)
index += 1
continue
}
guard !pastTerminator else {
remaining.append(argument)
index += 1
continue
}

let rawParameter: String?
if argument == "--param" {
guard index + 1 < args.count else {
throw CLIError(message: String(
format: String(
localized: "cli.workspace.templateParameter.error.requiresValue",
defaultValue: "%@: --param requires KEY=VALUE or KEY"
),
locale: .current,
commandName
))
}
rawParameter = args[index + 1]
index += 2
} else if argument.hasPrefix("--param=") {
rawParameter = String(argument.dropFirst("--param=".count))
index += 1
} else {
rawParameter = nil
}

guard let rawParameter else {
remaining.append(argument)
index += 1
continue
}
let parameter = try parseWorkspaceTemplateParameter(
rawParameter,
commandName: commandName,
environment: environment
)
values[parameter.key] = parameter.value
}
return WorkspaceTemplateParameterOptions(values: values, remaining: remaining)
}

private func parseWorkspaceTemplateParameter(
_ raw: String,
commandName: String,
environment: [String: String]
) throws -> (key: String, value: String) {
let key: String
let explicitValue: String?
if let equals = raw.firstIndex(of: "=") {
key = String(raw[..<equals]).trimmingCharacters(in: .whitespaces)
explicitValue = String(raw[raw.index(after: equals)...])
} else {
key = raw.trimmingCharacters(in: .whitespaces)
explicitValue = nil
}

guard CmuxTemplateVariable.isValidName(key) else {
throw CLIError(message: String(
format: String(
localized: "cli.workspace.templateParameter.error.invalidName",
defaultValue: "%@: invalid template parameter name '%@'"
),
locale: .current,
commandName,
key
))
}
if let explicitValue {
return (key, explicitValue)
}
guard let environmentValue = environment[key] else {
throw CLIError(message: String(
format: String(
localized: "cli.workspace.templateParameter.error.environmentMissing",
defaultValue: "%@: --param %@ could not import an unset environment variable"
),
locale: .current,
commandName,
key
))
}
return (key, environmentValue)
}
}
58 changes: 17 additions & 41 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7440,7 +7440,12 @@ struct CMUXCLI {
let (groupOpt, rem7) = parseOption(rem6, name: "--group")
let (groupPlacementOpt, rem8) = parseOption(rem7, name: "--group-placement")
let (groupReferenceOpt, rem9) = parseOption(rem8, name: "--group-reference")
let (envFiles, envPairs, remaining) = parseWorkspaceEnvOptions(rem9)
let (envFiles, envPairs, remainingWithParams) = parseWorkspaceEnvOptions(rem9)
let templateParameterOptions = try parseWorkspaceTemplateParameterOptions(
remainingWithParams,
commandName: commandName
)
let remaining = templateParameterOptions.remaining
if remaining.last == "--env" {
throw CLIError(message: String(
format: String(
Expand All @@ -7465,7 +7470,7 @@ struct CMUXCLI {
throw CLIError(message: String(
format: String(
localized: "cli.workspace.create.error.unknownFlag",
defaultValue: "%@: unknown flag '%@'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>, --group <id|ref>, --group-placement <afterCurrent|top|end>, --group-reference <workspace>"
defaultValue: "%@: unknown flag '%@'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --param KEY=VALUE, --layout <json>, --window <id|ref|index>, --focus <true|false>, --group <id|ref>, --group-placement <afterCurrent|top|end>, --group-reference <workspace>"
),
locale: .current,
commandName,
Expand All @@ -7475,7 +7480,8 @@ struct CMUXCLI {
var params: [String: Any] = [:]
try applyWindowOrCallerContext(to: &params, client: client, windowRaw: windowOpt ?? windowOverride)
if let cwdOpt {
params["cwd"] = resolvePath(cwdOpt)
params["cwd"] = cwdOpt
params["caller_cwd"] = FileManager.default.currentDirectoryPath
}
if let nameOpt { params["title"] = nameOpt }
if let descriptionOpt { params["description"] = descriptionOpt }
Expand All @@ -7486,6 +7492,12 @@ struct CMUXCLI {
if !workspaceEnv.isEmpty {
params["workspace_env"] = workspaceEnv
}
if !templateParameterOptions.values.isEmpty {
params["template_params"] = templateParameterOptions.values
}
if layoutOpt == nil, let commandOpt {
params["initial_input"] = unescapeSendText(commandOpt + "\\n")
}
if let layoutOpt {
guard let layoutData = layoutOpt.data(using: .utf8),
let layoutObj = try? JSONSerialization.jsonObject(with: layoutData) as? [String: Any] else {
Expand All @@ -7501,14 +7513,6 @@ struct CMUXCLI {
} else {
print("OK \(wsId)")
}
if layoutOpt == nil, let commandText = commandOpt, !wsId.isEmpty {
let text = unescapeSendText(commandText + "\\n")
let sendParams: [String: Any] = [
"text": text,
"workspace_id": wsId
]
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
}
}

/// Parses repeatable `--env KEY=VALUE` / `--env=KEY=VALUE` and
Expand Down Expand Up @@ -15730,35 +15734,7 @@ struct CMUXCLI {
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
"""
case "new-workspace":
return """
Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--env KEY=VALUE]... [--env-file <path>]... [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]

Create a new workspace in the caller's window.

Flags:
--name <title> Set a custom name for the new workspace
--description <text> Set a custom description for the new workspace
--cwd <path> Set the working directory for the new workspace
--command <text> Send text+Enter to the new workspace after creation
--env KEY=VALUE Set a workspace environment variable. Repeatable.
Reserved CMUX_* variables cannot be overridden.
--env-file <path> Load KEY=VALUE lines from a file. Repeatable.
--layout <json> Create workspace with a predefined split layout.
Layout surfaces define their own commands.
--window <id|ref|index> Target window (default: caller's window)
--focus <true|false> Focus the new workspace (default: false)
--group <id|ref> Add the new workspace to a workspace group
--group-placement afterCurrent|top|end Placement within --group (default: top)
--group-reference <workspace> Reference workspace for afterCurrent placement

Example:
cmux new-workspace
cmux new-workspace --name "Build Server"
cmux new-workspace --name "Launch" --description "Ship checklist"
cmux new-workspace --cwd ~/projects/myapp
cmux new-workspace --cwd . --command "npm test"
cmux new-workspace --name "Dev" --layout '{"direction":"horizontal","split":0.5,"children":[{"pane":{"surfaces":[{"type":"terminal","command":"vim"}]}},{"pane":{"surfaces":[{"type":"terminal","command":"npm run start"}]}}]}'
"""
return Self.workspaceCreateHelpText
case "list-workspaces":
return """
Usage: cmux list-workspaces [--window <id|ref|index>]
Expand Down Expand Up @@ -35247,7 +35223,7 @@ export default CMUXSessionRestore;
todo <add|list|check|uncheck|start|rm|clear> [args] [--workspace <id|ref|index>] [--window <id|ref|index>]
move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--title <text>] [--focus <true|false>]
list-workspaces [--window <id|ref|index>]
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--param KEY=VALUE]... [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]
ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [-A|--forward-agent] [-a|--no-forward-agent] [--ssh-option <opt>] [--window <id|ref|index>] [--no-focus] [-- <remote-command-args>]
ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] [--new-window]
ssh-session-list [--workspace <id|ref|index> | --all-workspaces]
Expand Down
34 changes: 26 additions & 8 deletions CLI/cmux_layout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@ import Foundation

extension CMUXCLI {
static func layoutHelpText() -> String {
"""
String(localized: "cli.layout.help", defaultValue: """
Usage: cmux layout <subcommand> [flags]

Save, list, export, open, and delete named workspace layouts.
Save, list, inspect, open, and delete named workspace layouts.

Subcommands:
save <name> [--workspace <ref>] [--overwrite] [--description <text>]
list [--json]
get <name>
open <name> [--cwd <dir>] [--focus <true|false>]
open <name> [--cwd <dir>] [--param KEY[=VALUE]]... [--focus <true|false>]
delete <name>

Examples:
cmux layout save dev --overwrite
cmux layout list
cmux layout get dev
cmux layout open dev --cwd ~/projects/myapp
"""
cmux layout open dev --cwd ~/projects/myapp --param port=4100
""")
}

func runLayoutNamespace(
Expand Down Expand Up @@ -136,17 +136,35 @@ extension CMUXCLI {
) throws {
let (cwdOpt, rem0) = parseOption(commandArgs, name: "--cwd")
let (focusOpt, rem1) = parseOption(rem0, name: "--focus")
let (windowOpt, remaining) = parseOption(rem1, name: "--window")
let (windowOpt, rem2) = parseOption(rem1, name: "--window")
let templateParameterOptions = try parseWorkspaceTemplateParameterOptions(
rem2,
commandName: "layout open"
)
let remaining = templateParameterOptions.remaining
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "layout open: unknown flag '\(unknown)'")
throw CLIError(message: String(
format: String(
localized: "cli.layout.open.error.unknownFlag",
defaultValue: "layout open: unknown flag '%@'"
),
locale: .current,
unknown
))
}
guard let name = remaining.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else {
throw CLIError(message: "layout open requires <name>")
}
let windowHandle = try normalizeWindowHandle(windowOpt ?? windowOverride, client: client)
var params: [String: Any] = ["name": name]
if let windowHandle { params["window_id"] = windowHandle }
if let cwdOpt { params["cwd"] = resolvePath(cwdOpt) }
if let cwdOpt {
params["cwd"] = cwdOpt
params["caller_cwd"] = FileManager.default.currentDirectoryPath
}
if !templateParameterOptions.values.isEmpty {
params["template_params"] = templateParameterOptions.values
}
try applyFocusOption(focusOpt, defaultValue: false, to: &params)
let payload = try client.sendV2(method: "layout.open", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,30 @@ extension ControlCommandCoordinator {
guard let name = string(params, "name") else {
return .err(code: "invalid_params", message: "Missing or blank name", data: nil)
}
let templateParameters: [String: String]
if hasNonNull(params, "template_params") {
guard case .object(let raw)? = params["template_params"],
raw.values.allSatisfy({ value in
if case .string = value { return true }
return false
}),
let parsed = stringMap(params, "template_params") else {
return .err(
code: "invalid_params",
message: "template_params must be an object with string values",
data: nil
)
}
templateParameters = parsed
} else {
templateParameters = [:]
}
let resolution = context?.controlLayoutOpen(
routing: routingSelectors(params),
name: name,
cwd: optionalTrimmedRawString(params, "cwd"),
callerCwd: optionalTrimmedRawString(params, "caller_cwd"),
templateParameters: templateParameters,
focusRequested: bool(params, "focus") ?? false
) ?? .tabManagerUnavailable
switch resolution {
Expand All @@ -115,6 +135,12 @@ extension ControlCommandCoordinator {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
case .corruptFile(let description):
return .err(code: "invalid_state", message: "layouts.json is corrupt", data: .object(["description": .string(description)]))
case .missingParameters(let names):
return .err(
code: "missing_parameters",
message: "Missing workspace template parameters: \(names.joined(separator: ", "))",
data: .object(["missing_parameters": .array(names.map(JSONValue.string))])
)
case .opened(let workspaceID):
return .ok(.object([
"workspace_id": .string(workspaceID.uuidString),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public enum ControlLayoutOpenResolution: Sendable {
case tabManagerUnavailable
/// The store file could not be decoded.
case corruptFile(String)
/// Required workspace template parameters were not supplied.
case missingParameters([String])
/// The workspace was opened.
case opened(workspaceID: UUID)
/// An unexpected store error occurred.
Expand Down Expand Up @@ -105,6 +107,8 @@ public protocol ControlLayoutContext: AnyObject {
routing: ControlRoutingSelectors,
name: String,
cwd: String?,
callerCwd: String?,
templateParameters: [String: String],
focusRequested: Bool
) -> ControlLayoutOpenResolution

Expand Down
Loading