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
6 changes: 3 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
20362 CLI/cmux.swift
20361 CLI/cmux.swift
17115 Sources/TerminalController.swift
15902 Sources/ContentView.swift
15919 Sources/ContentView.swift
14164 Sources/AppDelegate.swift
13686 Sources/Workspace.swift
13694 Sources/Workspace.swift
13403 Sources/GhosttyTerminalView.swift
10600 Sources/Panels/BrowserPanel.swift
8412 Sources/cmuxApp.swift
Expand Down
62 changes: 62 additions & 0 deletions CLI/CMUXCLI+MoveTabToNewWorkspace.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

extension CMUXCLI {
func applyTabActionFocusOption(_ focusOpt: String?, to params: inout [String: Any]) throws {
guard let focusOpt else { return }
guard let focus = parseBoolString(focusOpt) else {
throw CLIError(message: "--focus must be true|false")
}
params["focus"] = focus
}

func appendCreatedWorkspaceSummaryParts(
from payload: [String: Any],
idFormat: CLIIDFormat,
to summaryParts: inout [String]
) {
guard let id = payload["created_workspace_id"] as? String else { return }
var createdWorkspacePayload: [String: Any] = ["workspace_id": id]
if let ref = payload["created_workspace_ref"] as? String {
createdWorkspacePayload["workspace_ref"] = ref
}
if let createdWorkspace = formatHandle(createdWorkspacePayload, kind: "workspace", idFormat: idFormat) {
summaryParts.append("created_workspace=\(createdWorkspace)")
}
}

func runMoveTabToNewWorkspace(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
if commandArgs.contains(where: { $0 == "--action" || $0.hasPrefix("--action=") }) {
throw CLIError(message: "move-tab-to-new-workspace does not accept --action")
}
try runTabAction(
commandArgs: ["--action", "move-to-new-workspace"] + commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
}

static let moveTabToNewWorkspaceCommandHelp = """
Usage: cmux move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--focus <true|false>]

Move a tab into a newly created workspace in the same window.

Flags:
--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)
--surface <id|ref|index> Alias for --tab
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--title <text> Optional title for the new workspace
--focus <true|false> Focus the new workspace when supported (default: false)

Example:
cmux move-tab-to-new-workspace --tab tab:2
cmux move-tab-to-new-workspace --surface surface:3 --title "build logs"
"""
}
41 changes: 20 additions & 21 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2551,10 +2551,10 @@ struct CMUXCLI {

case "workspace-action":
try runWorkspaceAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)

case "tab-action":
try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)

case "move-tab-to-new-workspace", "detach-tab":
try runMoveTabToNewWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "rename-tab":
try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)

Expand Down Expand Up @@ -3767,7 +3767,7 @@ struct CMUXCLI {
return nil
}

private func parseBoolString(_ raw: String) -> Bool? {
func parseBoolString(_ raw: String) -> Bool? {
switch raw.lowercased() {
case "1", "true", "yes", "on":
return true
Expand Down Expand Up @@ -3966,7 +3966,7 @@ struct CMUXCLI {
return "tab:\(ordinal)"
}

private func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? {
func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? {
let id = payload["\(kind)_id"] as? String
let ref = payload["\(kind)_ref"] as? String
switch idFormat {
Expand Down Expand Up @@ -4396,7 +4396,7 @@ struct CMUXCLI {
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}

private func runTabAction(
func runTabAction(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
Expand All @@ -4409,8 +4409,9 @@ struct CMUXCLI {
let (actionOpt, rem3) = parseOption(rem2, name: "--action")
let (titleOpt, rem4) = parseOption(rem3, name: "--title")
let (urlOpt, rem5) = parseOption(rem4, name: "--url")
let (focusOpt, rem6) = parseOption(rem5, name: "--focus")

var positional = rem5
var positional = rem6
let actionRaw: String
if let actionOpt {
actionRaw = actionOpt
Expand Down Expand Up @@ -4464,24 +4465,16 @@ struct CMUXCLI {
if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines)
}

try applyTabActionFocusOption(focusOpt, to: &params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Set a non-focus default when --focus is omitted for move-to-new-workspace.

Line 4636 only applies focus when provided. For tab.action move-to-new-workspace, this means no focus key is sent, and the server path defaults to true, which can steal in-app focus unexpectedly.

Suggested fix
-        try applyTabActionFocusOption(focusOpt, to: &params)
+        if action == "move-to-new-workspace", focusOpt == nil {
+            params["focus"] = false
+        } else {
+            try applyTabActionFocusOption(focusOpt, to: &params)
+        }

Based on learnings: "Socket/CLI commands must not steal macOS app focus ... only explicit focus-intent commands may mutate in-app focus/selection ... all non-focus commands should preserve current user focus context."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try applyTabActionFocusOption(focusOpt, to: &params)
if action == "move-to-new-workspace", focusOpt == nil {
params["focus"] = false
} else {
try applyTabActionFocusOption(focusOpt, to: &params)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` at line 4636, For the "tab.action move-to-new-workspace"
path, ensure a non-focus default is sent when the CLI flag is omitted: after
calling applyTabActionFocusOption(focusOpt, to: &params) (or in that handler),
if focusOpt is nil explicitly set params["focus"] = false so the server doesn't
default to true and steal app focus; reference applyTabActionFocusOption, the
params dictionary, and the tab.action move-to-new-workspace handler when making
this change.

let payload = try client.sendV2(method: "tab.action", params: params)
var summaryParts = ["OK", "action=\(action)"]
if let tabHandle = formatTabHandle(payload, idFormat: idFormat) {
summaryParts.append("tab=\(tabHandle)")
}
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) {
summaryParts.append("workspace=\(workspaceHandle)")
}
if let closed = payload["closed"] {
summaryParts.append("closed=\(closed)")
}
if let created = formatCreatedTabHandle(payload, idFormat: idFormat) {
summaryParts.append("created=\(created)")
}
if let tabHandle = formatTabHandle(payload, idFormat: idFormat) { summaryParts.append("tab=\(tabHandle)") }
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { summaryParts.append("workspace=\(workspaceHandle)") }
if let closed = payload["closed"] { summaryParts.append("closed=\(closed)") }
if let created = formatCreatedTabHandle(payload, idFormat: idFormat) { summaryParts.append("created=\(created)") }
appendCreatedWorkspaceSummaryParts(from: payload, idFormat: idFormat, to: &summaryParts)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}

private func runRenameTab(
commandArgs: [String],
client: SocketClient,
Expand Down Expand Up @@ -8696,6 +8689,7 @@ struct CMUXCLI {
rename | clear-name
close-left | close-right | close-others
new-terminal-right | new-browser-right
move-to-new-workspace
reload | duplicate
pin | unpin
mark-unread
Expand All @@ -8707,12 +8701,16 @@ struct CMUXCLI {
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--title <text> Title for rename (or pass trailing title text)
--url <url> Optional URL for new-browser-right
--focus <true|false> Focus the destination when supported (default: false for move-to-new-workspace)

Example:
cmux tab-action --tab tab:3 --action pin
cmux tab-action --action close-right
cmux tab-action --tab tab:2 --action move-to-new-workspace
cmux tab-action --tab tab:2 --action rename --title "build logs"
"""
case "move-tab-to-new-workspace", "detach-tab":
return Self.moveTabToNewWorkspaceCommandHelp
case "rename-tab":
return """
Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
Expand Down Expand Up @@ -20212,6 +20210,7 @@ export default CMUXSessionRestore;
move-workspace-to-window --workspace <id|ref> --window <id|ref>
reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>]
workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>]
move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--focus <true|false>]
list-workspaces
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>]
ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>]
Expand All @@ -20227,7 +20226,7 @@ export default CMUXSessionRestore;
close-surface [--surface <id|ref>] [--workspace <id|ref>]
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>]
reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>)
tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] [--focus <true|false>]
rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title>
drag-surface-to-split --surface <id|ref> <left|right|up|down>
refresh-surfaces
Expand Down
Loading