Skip to content
Closed
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
94 changes: 90 additions & 4 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,7 @@ struct CMUXCLI {
// so help text is available even when cmux is not running.
if command != "__tmux-compat",
command != "claude-teams",
command != "claude-teams-debug",
command != "codex",
(commandArgs.contains("--help") || commandArgs.contains("-h")) {
if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {
Expand Down Expand Up @@ -1770,7 +1771,7 @@ struct CMUXCLI {
return
}

if command == "claude-teams" {
if command == "claude-teams" || command == "claude-teams-debug" {
try runClaudeTeams(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
Expand Down Expand Up @@ -6885,9 +6886,10 @@ struct CMUXCLI {
cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha"
cmux themes clear
"""
case "claude-teams":
case "claude-teams", "claude-teams-debug":
return String(localized: "cli.claude-teams.usage", defaultValue: """
Usage: cmux claude-teams [claude-args...]
cmux claude-teams-debug [claude-args...]

Launch Claude Code with agent teams enabled.

Expand Down Expand Up @@ -10146,6 +10148,81 @@ struct CMUXCLI {
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}

/// Parse a tmux pane style string (e.g. "bg=colour160,fg=white" or "bg=#C0392B")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Instance method with no self reference — consider static

parseTmuxStyleBackgroundHex closes over nothing from self; it only calls Self.xterm256ColorHex and Self.tmuxNamedColorHex (both already static). Marking it private static func would make the intent explicit and mirror the pattern of the two helpers it calls.

Suggested change
/// Parse a tmux pane style string (e.g. "bg=colour160,fg=white" or "bg=#C0392B")
private static func parseTmuxStyleBackgroundHex(_ style: String) -> String? {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

/// and extract the background color as a normalized #RRGGBB hex string.
private func parseTmuxStyleBackgroundHex(_ style: String) -> String? {
// Split style into key=value pairs: "bg=colour160,fg=white" → ["bg=colour160", "fg=white"]
let pairs = style.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard let bgPair = pairs.first(where: { $0.lowercased().hasPrefix("bg=") }) else {
return nil
}
let bgValue = String(bgPair.dropFirst(3)).trimmingCharacters(in: .whitespaces)
guard !bgValue.isEmpty, bgValue.lowercased() != "default" else { return nil }

// Case 1: Already a hex color (#RRGGBB or RRGGBB)
let stripped = bgValue.hasPrefix("#") ? String(bgValue.dropFirst()) : bgValue
if stripped.count == 6, UInt64(stripped, radix: 16) != nil {
return "#" + stripped.uppercased()
}

// Case 2: tmux colour0..colour255 (xterm-256color palette)
let lowered = bgValue.lowercased()
if lowered.hasPrefix("colour"), let n = Int(lowered.dropFirst("colour".count)), n >= 0, n < 256 {
return Self.xterm256ColorHex(n)
}
if lowered.hasPrefix("color"), let n = Int(lowered.dropFirst("color".count)), n >= 0, n < 256 {
return Self.xterm256ColorHex(n)
}

// Case 3: Named tmux colors
return Self.tmuxNamedColorHex(lowered)
}

private static func tmuxNamedColorHex(_ name: String) -> String? {
switch name {
case "black": return "#000000"
case "red": return "#CD0000"
case "green": return "#00CD00"
case "yellow": return "#CDCD00"
case "blue": return "#0000EE"
case "magenta": return "#CD00CD"
case "cyan": return "#00CDCD"
case "white": return "#E5E5E5"
case "brightblack": return "#7F7F7F"
case "brightred": return "#FF0000"
case "brightgreen": return "#00FF00"
case "brightyellow": return "#FFFF00"
case "brightblue": return "#5C5CFF"
case "brightmagenta": return "#FF00FF"
case "brightcyan": return "#00FFFF"
case "brightwhite": return "#FFFFFF"
default: return nil
}
}

/// Convert xterm-256 color index to #RRGGBB hex.
private static func xterm256ColorHex(_ n: Int) -> String? {
guard n >= 0, n < 256 else { return nil }
// Standard 16 colors (0-15)
let standard16: [String] = [
"#000000", "#CD0000", "#00CD00", "#CDCD00", "#0000EE", "#CD00CD", "#00CDCD", "#E5E5E5",
"#7F7F7F", "#FF0000", "#00FF00", "#FFFF00", "#5C5CFF", "#FF00FF", "#00FFFF", "#FFFFFF"
]
if n < 16 { return standard16[n] }
// 216-color cube (16-231)
if n < 232 {
let idx = n - 16
let r = idx / 36
let g = (idx % 36) / 6
let b = idx % 6
let vals = [0, 95, 135, 175, 215, 255]
return String(format: "#%02X%02X%02X", vals[r], vals[g], vals[b])
}
// Grayscale ramp (232-255)
let gray = 8 + (n - 232) * 10
return String(format: "#%02X%02X%02X", gray, gray, gray)
}

private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? {
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -11500,9 +11577,18 @@ struct CMUXCLI {

case "select-pane", "selectp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: [])
if parsed.value("-P") != nil || parsed.value("-T") != nil {
return
if let style = parsed.value("-P") {
if let hex = parseTmuxStyleBackgroundHex(style) {
if let target = try? tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) {
_ = try? client.sendV2(method: "surface.set_color", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"color": hex
])
}
}
}
guard parsed.value("-P") == nil, parsed.value("-T") == nil else { return }
let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "pane.focus", params: [
"workspace_id": target.workspaceId,
Expand Down
94 changes: 93 additions & 1 deletion Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12994,6 +12994,37 @@ private struct TabItemView: View, Equatable {
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
}

// Surface child rows for multi-surface workspaces (agent teams)
if tab.panels.count > 1 {
let orderedIds = tab.sidebarOrderedPanelIds()
let currentFocusedPanelId = tab.focusedPanelId
VStack(spacing: 2) {
ForEach(orderedIds, id: \.self) { panelId in
SurfaceChildRow(
title: tab.panelTitles[panelId] ?? String(localized: "sidebar.surfaceChild.defaultTitle", defaultValue: "Terminal"),
colorHex: tab.panelColors[panelId],
isFocused: panelId == currentFocusedPanelId,
isWorkspaceActive: isActive,
colorScheme: colorScheme
)
.contentShape(Rectangle())
.onTapGesture {
updateSelection()
tab.focusPanel(panelId)
}
.contextMenu {
Button(String(localized: "sidebar.surfaceChild.openInNewWorkspace", defaultValue: "Open in New Workspace")) {
tabManager.promoteSurfaceToNewWorkspace(
sourceWorkspace: tab,
panelId: panelId
)
}
}
Comment on lines +13017 to +13023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 New localization keys missing from Localizable.xcstrings

"sidebar.surfaceChild.defaultTitle" and "sidebar.surfaceChild.openInNewWorkspace" are correctly using String(localized:defaultValue:), but neither key has been added to Resources/Localizable.xcstrings. Per the project policy in CLAUDE.md, every key must be registered with translations for all supported languages (English and Japanese). Without the entries, Japanese users will see English fallback text for these strings.

}
}
.padding(.top, 4)
}
}
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
Expand Down Expand Up @@ -13440,14 +13471,23 @@ private struct TabItemView: View, Equatable {
}

private var resolvedCustomTabColor: Color? {
guard let hex = tab.customColor else { return nil }
let hex = tab.customColor ?? representativePanelColorHex
guard let hex else { return nil }
return WorkspaceTabColorSettings.displayColor(
hex: hex,
colorScheme: colorScheme,
forceBright: activeTabIndicatorStyle == .leftRail
)
}

/// When a workspace has no customColor but has panel colors (e.g. from agent teams),
/// use the first panel's color as the representative color for the workspace rail.
private var representativePanelColorHex: String? {
guard tab.customColor == nil, !tab.panelColors.isEmpty else { return nil }
let orderedIds = tab.sidebarOrderedPanelIds()
return orderedIds.lazy.compactMap { tab.panelColors[$0] }.first
}

private func tabColorSwatchColor(for hex: String) -> NSColor {
WorkspaceTabColorSettings.displayNSColor(
hex: hex,
Expand Down Expand Up @@ -14172,6 +14212,58 @@ private struct SidebarMetadataRows: View {
}
}

private struct SurfaceChildRow: View {
let title: String
let colorHex: String?
let isFocused: Bool
let isWorkspaceActive: Bool
let colorScheme: ColorScheme

var body: some View {
HStack(spacing: 5) {
Circle()
.fill(dotColor)
.frame(width: 6, height: 6)
Text(title)
.font(.system(size: 10))
.foregroundColor(textColor)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isFocused ? focusedBackgroundColor : Color.clear)
)
}

private var dotColor: Color {
if let hex = colorHex,
let color = WorkspaceTabColorSettings.displayColor(hex: hex, colorScheme: colorScheme) {
return color
}
return Color.secondary.opacity(0.5)
}

private var textColor: Color {
if isWorkspaceActive {
return isFocused
? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95))
: Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.7))
}
return isFocused ? .primary : .secondary
}

private var focusedBackgroundColor: Color {
if isWorkspaceActive {
return Color.white.opacity(0.1)
}
return Color.primary.opacity(0.05)
}
}

private struct SidebarMetadataEntryRow: View {
let entry: SidebarStatusEntry
let isActive: Bool
Expand Down
1 change: 1 addition & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable {
var customTitle: String?
var customDescription: String?
var customColor: String?
var panelColors: [String: String]? // panelId.uuidString -> hex
var isPinned: Bool
var currentDirectory: String
var focusedPanelId: UUID?
Expand Down
47 changes: 47 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3561,6 +3561,53 @@ class TabManager: ObservableObject {
tab.setCustomColor(color)
}

/// Detach a surface from its current workspace and open it in a new workspace as an independent tab.
func promoteSurfaceToNewWorkspace(sourceWorkspace: Workspace, panelId: UUID) {
guard sourceWorkspace.panels[panelId] != nil else { return }
guard sourceWorkspace.panels.count > 1 else { return }

// Transfer panel color before detach (detach removes it from the source)
let panelColor = sourceWorkspace.panelColors[panelId]
let panelTitle = sourceWorkspace.panelTitles[panelId]

guard let transfer = sourceWorkspace.detachSurface(panelId: panelId) else { return }

let newWorkspace = addWorkspace(
title: panelTitle ?? transfer.title,
select: true,
eagerLoadTerminal: false
)

guard let targetPane = newWorkspace.bonsplitController.allPaneIds.first else {
// Rollback: reattach to source
if let rollbackPane = sourceWorkspace.bonsplitController.allPaneIds.first {
sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, focus: true)
}
return
}

// Identify the dummy terminal that addWorkspace auto-created
let dummyPanelIds = Set(newWorkspace.panels.keys)

guard newWorkspace.attachDetachedSurface(transfer, inPane: targetPane, focus: true) != nil else {
// Rollback: reattach to source
if let rollbackPane = sourceWorkspace.bonsplitController.allPaneIds.first {
sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, focus: true)
}
return
}

// Close the dummy terminal surface that was auto-created with the new workspace
for dummyId in dummyPanelIds {
_ = newWorkspace.closePanel(dummyId, force: true)
}

// Apply the panel color as the new workspace's custom color
if let panelColor {
newWorkspace.setCustomColor(panelColor)
}
}

func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
Expand Down
34 changes: 34 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,8 @@ class TerminalController {
return v2Result(id: id, self.v2SurfaceClose(params: params))
case "surface.move":
return v2Result(id: id, self.v2SurfaceMove(params: params))
case "surface.set_color":
return v2Result(id: id, self.v2SurfaceSetColor(params: params))
case "surface.reorder":
return v2Result(id: id, self.v2SurfaceReorder(params: params))
case "surface.action":
Expand Down Expand Up @@ -4914,6 +4916,38 @@ class TerminalController {
return result
}

private func v2SurfaceSetColor(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}

var result: V2CallResult = .err(code: "internal_error", message: "Failed to set surface color", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let surfaceId = v2UUID(params, "surface_id"), ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: nil)
return
}

let colorRaw = v2String(params, "color")?.trimmingCharacters(in: .whitespacesAndNewlines)
Comment on lines +4929 to +4935

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant double normalization

v2SurfaceSetColor normalizes colorRaw via WorkspaceTabColorSettings.normalizedHex(colorRaw), then passes the already-normalized string to ws.setPanelColor, which calls normalizedHex a second time. setPanelColor can be called directly with the raw input and the normalization happens exactly once inside it.

Suggested change
}
guard let surfaceId = v2UUID(params, "surface_id"), ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: nil)
return
}
let colorRaw = v2String(params, "color")?.trimmingCharacters(in: .whitespacesAndNewlines)
let colorRaw = v2String(params, "color")?.trimmingCharacters(in: .whitespacesAndNewlines)
if let colorRaw, !colorRaw.isEmpty {
ws.setPanelColor(surfaceId, hex: colorRaw)
if let stored = ws.panelColors[surfaceId] {
result = .ok(["surface_id": surfaceId.uuidString, "color": stored])
} else {
result = .err(code: "invalid_params", message: "Invalid color hex", data: nil)
}

if let colorRaw, !colorRaw.isEmpty {
if let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) {
ws.setPanelColor(surfaceId, hex: normalized)
result = .ok(["surface_id": surfaceId.uuidString, "color": normalized])
} else {
result = .err(code: "invalid_params", message: "Invalid color hex", data: nil)
}
} else {
ws.setPanelColor(surfaceId, hex: nil)
result = .ok(["surface_id": surfaceId.uuidString, "color": NSNull()])
}
}
return result
}

private func v2SurfaceSplit(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
Expand Down
Loading