From 6bca50b59e5cee4e3756392e8b10aeb8b9505f40 Mon Sep 17 00:00:00 2001 From: apollow Date: Sun, 8 Mar 2026 16:32:51 -0700 Subject: [PATCH 1/6] Add workspace.set_color API and color-coded notifications Add programmatic workspace color control via CLI and socket API, and propagate workspace colors to notifications for visual identification. New features: - workspace.set_color v2 method + set-workspace-color CLI command - custom_color field in workspace.list response - Notifications auto-inherit workspace color (dot + left-edge bar) - --color flag on cmux notify for explicit override - Skill documentation in skills/cmux-workspace-colors/ Co-Authored-By: Claude Opus 4.6 --- CLI/cmux.swift | 70 +++++++++++++-- Sources/GhosttyTerminalView.swift | 8 +- Sources/NotificationsPage.swift | 28 +++++- Sources/TerminalController.swift | 85 +++++++++++++++++-- Sources/TerminalNotificationStore.swift | 4 +- skills/cmux-workspace-colors/SKILL.md | 65 ++++++++++++++ .../references/commands.md | 81 ++++++++++++++++++ 7 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 skills/cmux-workspace-colors/SKILL.md create mode 100644 skills/cmux-workspace-colors/references/commands.md diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 449897960d27..aa3f1973240c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1363,6 +1363,28 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.rename", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "set-workspace-color": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let isClear = rem0.contains("--clear") + let rem1 = rem0.filter { $0 != "--clear" } + let hasPositionalArgs = !rem1.filter({ !$0.hasPrefix("--") }).isEmpty + if isClear, hasPositionalArgs { + throw CLIError(message: "set-workspace-color: --clear and a color value are mutually exclusive") + } + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + var params: [String: Any] = ["workspace_id": wsId] + if !isClear { + let colorArgs = rem1.dropFirst(rem1.first == "--" ? 1 : 0) + let color = colorArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !color.isEmpty else { + throw CLIError(message: "set-workspace-color requires a color hex (e.g. \"#C0392B\") or --clear") + } + params["color"] = color + } + let payload = try client.sendV2(method: "workspace.set_color", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "current-workspace": let response = try sendV1Command("current_workspace", client: client) if jsonOutput { @@ -1479,17 +1501,30 @@ struct CMUXCLI { let title = optionValue(commandArgs, name: "--title") ?? "Notification" let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" + let color = optionValue(commandArgs, name: "--color") let notifyWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) - let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client) - let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) - let payload = "\(title)|\(subtitle)|\(body)" - let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) - print(response) + var params: [String: Any] = ["title": title, "subtitle": subtitle, "body": body] + if let wsId { params["workspace_id"] = wsId } + if let sfId { params["surface_id"] = sfId } + if let color { params["color"] = color } + + let method: String + if wsId != nil, sfId != nil { + method = "notification.create_for_target" + } else if sfId != nil { + method = "notification.create_for_surface" + } else { + method = "notification.create" + } + let payload = try client.sendV2(method: method, params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "list-notifications": let response = try sendV1Command("list_notifications", client: client) @@ -4531,6 +4566,21 @@ struct CMUXCLI { cmux rename-workspace "backend logs" cmux rename-window --workspace workspace:2 "agent run" """ + case "set-workspace-color": + return """ + Usage: cmux set-workspace-color [--workspace ] [--clear] [--] <#hex> + + Set or clear the custom color for a workspace tab. + + Flags: + --workspace Workspace to color (default: current/$CMUX_WORKSPACE_ID) + --clear Remove the custom color + + Example: + cmux set-workspace-color "#C0392B" + cmux set-workspace-color --workspace workspace:2 "#1565C0" + cmux set-workspace-color --clear + """ case "current-workspace": return """ Usage: cmux current-workspace @@ -4799,18 +4849,21 @@ struct CMUXCLI { return """ Usage: cmux notify [flags] - Send a notification to a workspace/surface. + Send a notification to a workspace/surface. If the workspace has a custom + color and no explicit --color is given, the notification inherits the + workspace color automatically. Flags: --title Notification title (default: "Notification") --subtitle Notification subtitle --body Notification body + --color <#hex> Notification accent color (default: workspace color or blue) --workspace Target workspace (default: $CMUX_WORKSPACE_ID) --surface Target surface (default: $CMUX_SURFACE_ID) Example: cmux notify --title "Build done" --body "All tests passed" - cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" + cmux notify --title "Error" --body "Failed" --color "#C0392B" """ case "list-notifications": return """ @@ -6950,13 +7003,14 @@ struct CMUXCLI { select-workspace --workspace rename-workspace [--workspace ] rename-window [--workspace <id|ref>] <title> + set-workspace-color [--workspace <id|ref>] [--clear] <#hex> current-workspace read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> send-key [--workspace <id|ref>] [--surface <id|ref>] <key> send-panel --panel <id|ref> [--workspace <id|ref>] <text> send-key-panel --panel <id|ref> [--workspace <id|ref>] <key> - notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>] + notify --title <text> [--subtitle <text>] [--body <text>] [--color <#hex>] [--workspace <id|ref>] [--surface <id|ref>] list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4e2139e451fc..8a12b3c04937 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1463,12 +1463,14 @@ class GhosttyApp { let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) + let wsColor = tabManager.tabs.first(where: { $0.id == tabId })?.customColor TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", - body: body + body: body, + color: wsColor ) return true } @@ -1722,12 +1724,14 @@ class GhosttyApp { let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody + let wsColor = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == tabId })?.customColor TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", - body: body + body: body, + color: wsColor ) } return true diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 096dce205a63..2fefe55de25e 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -172,22 +172,31 @@ private struct ShortcutAnnotation: View { } private struct NotificationRow: View { + @Environment(\.colorScheme) private var colorScheme let notification: TerminalNotification let tabTitle: String? let onOpen: () -> Void let onClear: () -> Void let focusedNotificationId: FocusState<UUID?>.Binding + private var dotColor: Color { + if let hex = notification.color, + let custom = WorkspaceTabColorSettings.displayColor(hex: hex, colorScheme: colorScheme) { + return custom + } + return cmuxAccentColor() + } + var body: some View { HStack(alignment: .top, spacing: 12) { Button(action: onOpen) { HStack(alignment: .top, spacing: 12) { Circle() - .fill(notification.isRead ? Color.clear : cmuxAccentColor()) + .fill(notification.isRead ? Color.clear : dotColor) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(dotColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) @@ -236,8 +245,19 @@ private struct NotificationRow: View { } .padding(12) .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(nsColor: .controlBackgroundColor)) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + if notification.color != nil { + HStack(spacing: 0) { + Rectangle() + .fill(dotColor) + .frame(width: 3) + Spacer() + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) ) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1f13435365d9..652c4b0bd625 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1728,6 +1728,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceReorder(params: params)) case "workspace.rename": return v2Result(id: id, self.v2WorkspaceRename(params: params)) + case "workspace.set_color": + return v2Result(id: id, self.v2WorkspaceSetColor(params: params)) case "workspace.action": return v2Result(id: id, self.v2WorkspaceAction(params: params)) case "workspace.next": @@ -2092,6 +2094,7 @@ class TerminalController { "workspace.move_to_window", "workspace.reorder", "workspace.rename", + "workspace.set_color", "workspace.action", "workspace.next", "workspace.previous", @@ -2863,6 +2866,7 @@ class TerminalController { "ref": v2Ref(kind: .workspace, uuid: ws.id), "index": index, "title": ws.title, + "custom_color": v2OrNull(ws.customColor), "selected": ws.id == tabManager.selectedTabId, "pinned": ws.isPinned ] @@ -3119,6 +3123,49 @@ class TerminalController { "title": title ]) } + private func v2WorkspaceSetColor(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + + let colorRaw = v2String(params, "color") + let color: String? + if let colorRaw { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + } + color = normalized + } else { + color = nil + } + + var applied = false + // TabManager and Workspace are @MainActor-bound; color mutation requires main-thread execution. + v2MainSync { + guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + tabManager.setTabColor(tabId: workspaceId, color: color) + applied = true + } + + guard applied else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ]) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "color": v2OrNull(color) + ]) + } private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -5263,6 +5310,7 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" + let color = v2String(params, "color") var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5271,14 +5319,24 @@ class TerminalController { return } let surfaceId = ws.focusedPanelId + let resolvedColor = color ?? ws.customColor TerminalNotificationStore.shared.addNotification( tabId: ws.id, surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + color: resolvedColor ) - result = .ok(["workspace_id": ws.id.uuidString, "surface_id": v2OrNull(surfaceId?.uuidString)]) + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": v2OrNull(surfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) } return result } @@ -5294,6 +5352,7 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" + let color = v2String(params, "color") var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5305,12 +5364,14 @@ class TerminalController { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) return } + let resolvedColor = color ?? ws.customColor TerminalNotificationStore.shared.addNotification( tabId: ws.id, surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + color: resolvedColor ) result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } @@ -5331,6 +5392,7 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" + let color = v2String(params, "color") var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5342,12 +5404,14 @@ class TerminalController { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) return } + let resolvedColor = color ?? ws.customColor TerminalNotificationStore.shared.addNotification( tabId: ws.id, surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + color: resolvedColor ) result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } @@ -5365,7 +5429,8 @@ class TerminalController { "is_read": n.isRead, "title": n.title, "subtitle": n.subtitle, - "body": n.body + "body": n.body, + "color": v2OrNull(n.color) ] } } @@ -10808,12 +10873,14 @@ class TerminalController { } let surfaceId = tabManager.focusedSurfaceId(for: tabId) let (title, subtitle, body) = parseNotificationPayload(args) + let wsColor = tabManager.tabs.first(where: { $0.id == tabId })?.customColor TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + color: wsColor ) } return result @@ -10845,7 +10912,8 @@ class TerminalController { surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + color: tab.customColor ) } return result @@ -10886,7 +10954,8 @@ class TerminalController { surfaceId: panelId, title: title, subtitle: subtitle, - body: body + body: body, + color: tab.customColor ) } return result diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb3f13..c00097188e92 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -611,6 +611,7 @@ struct TerminalNotification: Identifiable, Hashable { let title: String let subtitle: String let body: String + let color: String? let createdAt: Date var isRead: Bool } @@ -819,7 +820,7 @@ final class TerminalNotificationStore: ObservableObject { indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } - func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) { + func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String, color: String? = nil) { var updated = notifications var idsToClear: [String] = [] updated.removeAll { existing in @@ -853,6 +854,7 @@ final class TerminalNotificationStore: ObservableObject { title: title, subtitle: subtitle, body: body, + color: color, createdAt: Date(), isRead: false ) diff --git a/skills/cmux-workspace-colors/SKILL.md b/skills/cmux-workspace-colors/SKILL.md new file mode 100644 index 000000000000..c703783e5768 --- /dev/null +++ b/skills/cmux-workspace-colors/SKILL.md @@ -0,0 +1,65 @@ +--- +name: cmux-workspace-colors +description: Color-code cmux workspaces and get color-tagged notifications that visually identify their source workspace. +--- + +# Workspace Colors + +Assign custom colors to workspaces for visual organization. Notifications from colored workspaces automatically inherit the workspace color. + +## Quick Start + +```bash +# Set a workspace color +cmux set-workspace-color "#C0392B" +cmux set-workspace-color --workspace workspace:2 "#1565C0" + +# Clear a workspace color +cmux set-workspace-color --clear + +# Send a notification (inherits workspace color automatically) +cmux notify --title "Build done" --body "All tests passed" + +# Override notification color explicitly +cmux notify --title "Error" --body "Build failed" --color "#C0392B" + +# Check workspace colors +cmux list-workspaces --json +``` + +## How It Works + +1. **Workspace color** is set via `set-workspace-color` and persists across sessions. +2. **Notifications** inherit the source workspace's color automatically. The notification dot and left-edge bar in the notifications panel render in the workspace color. +3. An explicit `--color` flag on `cmux notify` overrides the workspace default. + +## Color Palette + +cmux includes 16 default colors: + +| Name | Hex | +|------|-----| +| Red | #C0392B | +| Crimson | #922B21 | +| Orange | #A04000 | +| Amber | #7D6608 | +| Olive | #4A5C18 | +| Green | #196F3D | +| Teal | #006B6B | +| Aqua | #0E6B8C | +| Blue | #1565C0 | +| Navy | #1A5276 | +| Indigo | #283593 | +| Purple | #6A1B9A | +| Magenta | #AD1457 | +| Rose | #880E4F | +| Brown | #7B3F00 | +| Charcoal | #3E4B5E | + +Any `#RRGGBB` hex value is accepted. + +## Deep-Dive References + +| Reference | When to Use | +|-----------|-------------| +| [references/commands.md](references/commands.md) | Full command reference | diff --git a/skills/cmux-workspace-colors/references/commands.md b/skills/cmux-workspace-colors/references/commands.md new file mode 100644 index 000000000000..cab57a543dec --- /dev/null +++ b/skills/cmux-workspace-colors/references/commands.md @@ -0,0 +1,81 @@ +# Workspace Color Commands + +## set-workspace-color + +Set or clear the custom color for a workspace tab. + +```bash +cmux set-workspace-color [--workspace <id|ref>] [--clear] [--] <#hex> +``` + +**Flags:** +- `--workspace <id|ref>` - Target workspace (default: current / `$CMUX_WORKSPACE_ID`) +- `--clear` - Remove the custom color + +**Examples:** +```bash +cmux set-workspace-color "#C0392B" +cmux set-workspace-color --workspace workspace:2 "#1565C0" +cmux set-workspace-color --workspace workspace:3 --clear +``` + +**Socket method:** `workspace.set_color` +```json +{ "workspace_id": "<uuid>", "color": "#C0392B" } +``` +Omit `color` to clear. + +## notify (color support) + +The `notify` command now accepts an optional `--color` flag. If omitted, notifications automatically inherit the source workspace's `customColor`. + +```bash +cmux notify --title <text> [--body <text>] [--color <#hex>] +``` + +**Examples:** +```bash +# Inherits workspace color +cmux notify --title "Done" --body "Task complete" + +# Explicit override +cmux notify --title "Alert" --body "Disk full" --color "#C0392B" +``` + +## list-workspaces (color in response) + +`workspace.list` now includes `custom_color` in each workspace entry: + +```json +{ + "workspaces": [ + { + "id": "...", + "ref": "workspace:1", + "title": "backend", + "custom_color": "#C0392B", + "selected": true, + "pinned": false + } + ] +} +``` + +`custom_color` is `null` when no color is set. + +## list-notifications (color in response) + +`notification.list` now includes `color` in each notification entry: + +```json +{ + "notifications": [ + { + "id": "...", + "title": "Build done", + "color": "#C0392B", + "is_read": false + } + ] +} +``` From 56d194225b0c014e95a5501ca39f7978600ebebb Mon Sep 17 00:00:00 2001 From: apollow <yellowy4321@gmail.com> Date: Sun, 8 Mar 2026 17:05:04 -0700 Subject: [PATCH 2/6] Address PR review feedback - Validate color hex in all 3 notification creation methods - Cache v2ResolveWindowId to avoid duplicate calls - Reject unknown flags in set-workspace-color CLI - Handle non-string color params in workspace.set_color - Fix usage text to show --clear and hex as mutually exclusive Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CLI/cmux.swift | 7 +++-- Sources/TerminalController.swift | 51 +++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index aa3f1973240c..76186cd57954 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1368,6 +1368,9 @@ struct CMUXCLI { let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let isClear = rem0.contains("--clear") let rem1 = rem0.filter { $0 != "--clear" } + if let unknownFlag = rem1.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + throw CLIError(message: "set-workspace-color: unknown flag '\(unknownFlag)'") + } let hasPositionalArgs = !rem1.filter({ !$0.hasPrefix("--") }).isEmpty if isClear, hasPositionalArgs { throw CLIError(message: "set-workspace-color: --clear and a color value are mutually exclusive") @@ -4568,7 +4571,7 @@ struct CMUXCLI { """ case "set-workspace-color": return """ - Usage: cmux set-workspace-color [--workspace <id|ref>] [--clear] [--] <#hex> + Usage: cmux set-workspace-color [--workspace <id|ref|index>] (--clear | [--] <#hex>) Set or clear the custom color for a workspace tab. @@ -7003,7 +7006,7 @@ struct CMUXCLI { select-workspace --workspace <id|ref> rename-workspace [--workspace <id|ref>] <title> rename-window [--workspace <id|ref>] <title> - set-workspace-color [--workspace <id|ref>] [--clear] <#hex> + set-workspace-color [--workspace <id|ref|index>] (--clear | <#hex>) current-workspace read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 652c4b0bd625..9f96c83773bd 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3131,13 +3131,18 @@ class TerminalController { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) } - let colorRaw = v2String(params, "color") let color: String? - if let colorRaw { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) else { - return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + if params.keys.contains("color") { + if params["color"] is NSNull { + color = nil + } else if let colorRaw = params["color"] as? String { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + } + color = normalized + } else { + return .err(code: "invalid_params", message: "color must be a string or null", data: nil) } - color = normalized } else { color = nil } @@ -5310,7 +5315,15 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - let color = v2String(params, "color") + let color: String? + if let raw = v2String(params, "color") { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + } + color = normalized + } else { + color = nil + } var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5352,7 +5365,15 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - let color = v2String(params, "color") + let color: String? + if let raw = v2String(params, "color") { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + } + color = normalized + } else { + color = nil + } var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5373,7 +5394,8 @@ class TerminalController { body: body, color: resolvedColor ) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId)]) } return result } @@ -5392,7 +5414,15 @@ class TerminalController { let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - let color = v2String(params, "color") + let color: String? + if let raw = v2String(params, "color") { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) + } + color = normalized + } else { + color = nil + } var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) v2MainSync { @@ -5413,7 +5443,8 @@ class TerminalController { body: body, color: resolvedColor ) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId)]) } return result } From 2b019e1eba6c3f2dfba124050f48d807fa428e49 Mon Sep 17 00:00:00 2001 From: apollow <yellowy4321@gmail.com> Date: Sun, 8 Mar 2026 17:08:10 -0700 Subject: [PATCH 3/6] Fix --workspace flag description to include index option Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CLI/cmux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 76186cd57954..fc66c28f30fe 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4576,7 +4576,7 @@ struct CMUXCLI { Set or clear the custom color for a workspace tab. Flags: - --workspace <id|ref> Workspace to color (default: current/$CMUX_WORKSPACE_ID) + --workspace <id|ref|index> Workspace to color (default: current/$CMUX_WORKSPACE_ID) --clear Remove the custom color Example: From e6ae5a275f70ab4ac29dae2188c2c2805f2eb53f Mon Sep 17 00:00:00 2001 From: apollow <yellowy4321@gmail.com> Date: Sun, 8 Mar 2026 17:11:48 -0700 Subject: [PATCH 4/6] Require explicit color key in workspace.set_color, update docs - Missing color key now returns invalid_params (only null clears) - CLI --clear sends explicit NSNull instead of omitting key - Update skill docs: usage syntax, set/clear examples Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CLI/cmux.swift | 4 +++- Sources/TerminalController.swift | 22 +++++++++---------- .../references/commands.md | 11 ++++++---- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fc66c28f30fe..857290789efe 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1377,7 +1377,9 @@ struct CMUXCLI { } let wsId = try resolveWorkspaceId(workspaceArg, client: client) var params: [String: Any] = ["workspace_id": wsId] - if !isClear { + if isClear { + params["color"] = NSNull() + } else { let colorArgs = rem1.dropFirst(rem1.first == "--" ? 1 : 0) let color = colorArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !color.isEmpty else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9f96c83773bd..91654da877e7 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3131,20 +3131,20 @@ class TerminalController { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) } + guard params.keys.contains("color") else { + return .err(code: "invalid_params", message: "Missing color. Use null to clear.", data: nil) + } + let color: String? - if params.keys.contains("color") { - if params["color"] is NSNull { - color = nil - } else if let colorRaw = params["color"] as? String { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) else { - return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) - } - color = normalized - } else { - return .err(code: "invalid_params", message: "color must be a string or null", data: nil) + if params["color"] is NSNull { + color = nil + } else if let colorRaw = params["color"] as? String { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(colorRaw) else { + return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) } + color = normalized } else { - color = nil + return .err(code: "invalid_params", message: "color must be a string or null", data: nil) } var applied = false diff --git a/skills/cmux-workspace-colors/references/commands.md b/skills/cmux-workspace-colors/references/commands.md index cab57a543dec..ecf8db452dc3 100644 --- a/skills/cmux-workspace-colors/references/commands.md +++ b/skills/cmux-workspace-colors/references/commands.md @@ -5,12 +5,12 @@ Set or clear the custom color for a workspace tab. ```bash -cmux set-workspace-color [--workspace <id|ref>] [--clear] [--] <#hex> +cmux set-workspace-color [--workspace <id|ref|index>] (--clear | [--] <#hex>) ``` **Flags:** -- `--workspace <id|ref>` - Target workspace (default: current / `$CMUX_WORKSPACE_ID`) -- `--clear` - Remove the custom color +- `--workspace <id|ref|index>` - Target workspace (default: current / `$CMUX_WORKSPACE_ID`) +- `--clear` - Remove the custom color (mutually exclusive with `<#hex>`) **Examples:** ```bash @@ -21,9 +21,12 @@ cmux set-workspace-color --workspace workspace:3 --clear **Socket method:** `workspace.set_color` ```json +// Set color { "workspace_id": "<uuid>", "color": "#C0392B" } + +// Clear color +{ "workspace_id": "<uuid>", "color": null } ``` -Omit `color` to clear. ## notify (color support) From 16ab61d2cfbc96502bdb34147f6db0f87aa634e4 Mon Sep 17 00:00:00 2001 From: apollow <yellowy4321@gmail.com> Date: Sun, 8 Mar 2026 17:20:56 -0700 Subject: [PATCH 5/6] Stricter color validation and CLI robustness - Notification color: reject non-string/invalid types (not just strings) - CLI notify: validate --color has a value before sending request - Update notify help text to show index option for workspace/surface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CLI/cmux.swift | 7 +++++-- Sources/TerminalController.swift | 27 ++++++++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 857290789efe..5006df6dc41f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1507,6 +1507,9 @@ struct CMUXCLI { let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" let color = optionValue(commandArgs, name: "--color") + if commandArgs.contains("--color"), color == nil || (color?.hasPrefix("-") ?? false) { + throw CLIError(message: "notify: --color requires a hex value (e.g. \"#C0392B\")") + } let notifyWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -4863,8 +4866,8 @@ struct CMUXCLI { --subtitle <text> Notification subtitle --body <text> Notification body --color <#hex> Notification accent color (default: workspace color or blue) - --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) + --workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID) + --surface <id|ref|index> Target surface (default: $CMUX_SURFACE_ID) Example: cmux notify --title "Build done" --body "All tests passed" diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 91654da877e7..74d1ffd09e3f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5316,11 +5316,14 @@ class TerminalController { let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" let color: String? - if let raw = v2String(params, "color") { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + if let rawValue = params["color"] { + if rawValue is NSNull { + color = nil + } else if let raw = rawValue as? String, let normalized = WorkspaceTabColorSettings.normalizedHex(raw) { + color = normalized + } else { return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) } - color = normalized } else { color = nil } @@ -5366,11 +5369,14 @@ class TerminalController { let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" let color: String? - if let raw = v2String(params, "color") { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + if let rawValue = params["color"] { + if rawValue is NSNull { + color = nil + } else if let raw = rawValue as? String, let normalized = WorkspaceTabColorSettings.normalizedHex(raw) { + color = normalized + } else { return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) } - color = normalized } else { color = nil } @@ -5415,11 +5421,14 @@ class TerminalController { let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" let color: String? - if let raw = v2String(params, "color") { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + if let rawValue = params["color"] { + if rawValue is NSNull { + color = nil + } else if let raw = rawValue as? String, let normalized = WorkspaceTabColorSettings.normalizedHex(raw) { + color = normalized + } else { return .err(code: "invalid_params", message: "Invalid color hex. Use format #RRGGBB.", data: nil) } - color = normalized } else { color = nil } From 3bf469e37b600bb1b05044da2225158519daa1d9 Mon Sep 17 00:00:00 2001 From: apollow <yellowy4321@gmail.com> Date: Sun, 8 Mar 2026 17:31:01 -0700 Subject: [PATCH 6/6] Return color in notification create responses, fix notify usage text - All 3 notification create methods now include color in response - Top-level notify usage text shows id|ref|index for workspace/surface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CLI/cmux.swift | 2 +- Sources/TerminalController.swift | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5006df6dc41f..193ce074d4d4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7018,7 +7018,7 @@ struct CMUXCLI { send-key [--workspace <id|ref>] [--surface <id|ref>] <key> send-panel --panel <id|ref> [--workspace <id|ref>] <text> send-key-panel --panel <id|ref> [--workspace <id|ref>] <key> - notify --title <text> [--subtitle <text>] [--body <text>] [--color <#hex>] [--workspace <id|ref>] [--surface <id|ref>] + notify --title <text> [--subtitle <text>] [--body <text>] [--color <#hex>] [--workspace <id|ref|index>] [--surface <id|ref|index>] list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 74d1ffd09e3f..b628254989cc 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5351,7 +5351,8 @@ class TerminalController { "surface_id": v2OrNull(surfaceId?.uuidString), "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId) + "window_ref": v2Ref(kind: .window, uuid: windowId), + "color": v2OrNull(resolvedColor) ]) } return result @@ -5401,7 +5402,7 @@ class TerminalController { color: resolvedColor ) let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId)]) + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "color": v2OrNull(resolvedColor)]) } return result } @@ -5453,7 +5454,7 @@ class TerminalController { color: resolvedColor ) let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId)]) + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "color": v2OrNull(resolvedColor)]) } return result }