Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6e6baa3
Replace the focus double blink with one short pulse
teamleaderleo Sep 27, 2026
3d08f3b
Don't flash a pane when typing is what dismissed its notification
teamleaderleo Sep 27, 2026
391819a
Add a Double Blink setting for the pane flash
teamleaderleo Sep 27, 2026
910e506
Merge main (9a5f92679b22) into calm-focus-pulse
teamleaderleo Sep 27, 2026
9df5058
Merge remote-tracking branch 'mf/main' into calm-focus-pulse
teamleaderleo Sep 27, 2026
11d41fb
Merge commit '0c151d1c95d83b1b1f7f784eb5a3669a404b499f' into calm-foc…
teamleaderleo Sep 27, 2026
b7c29c5
Merge main into calm-focus-pulse
teamleaderleo Sep 28, 2026
655601a
Pane flash: a 0.6 s pulse, which won the side-by-side vote over 0.4 s
teamleaderleo Sep 28, 2026
d5323c9
Pane flash: follow the terminal theme foreground by default
teamleaderleo Sep 28, 2026
c9b2b7f
Merge main into calm-focus-pulse
teamleaderleo Sep 28, 2026
dd3c839
Test typing and clicking dismissal against a live surface
teamleaderleo Sep 28, 2026
72ef1fd
Merge main into calm-focus-pulse
teamleaderleo Sep 28, 2026
ceeb317
Merge remote-tracking branch 'origin/main' into calm-focus-pulse
teamleaderleo Sep 30, 2026
02898f0
fix: keep double blink as default focus flash
teamleaderleo Sep 30, 2026
662d005
test: assert double blink is the default
teamleaderleo Sep 30, 2026
a811385
feat: make focus flash color and pattern configurable
teamleaderleo Sep 30, 2026
832235f
fix: keep catalog default double blink and expose theme flash color
teamleaderleo Sep 30, 2026
790ea9d
fix localization coverage for machine and session settings
teamleaderleo Sep 30, 2026
e25a80e
Replace the focus double blink with one short pulse
teamleaderleo Sep 27, 2026
e09a396
Don't flash a pane when typing is what dismissed its notification
teamleaderleo Sep 27, 2026
ed5f4b5
Add a Double Blink setting for the pane flash
teamleaderleo Sep 27, 2026
3e8f0e9
Pane flash: a 0.6 s pulse, which won the side-by-side vote over 0.4 s
teamleaderleo Sep 28, 2026
3945e25
Pane flash: follow the terminal theme foreground by default
teamleaderleo Sep 28, 2026
9b5814a
Test typing and clicking dismissal against a live surface
teamleaderleo Sep 28, 2026
c0e48b3
fix: keep double blink as default focus flash
teamleaderleo Sep 30, 2026
72a9f58
test: assert double blink is the default
teamleaderleo Sep 30, 2026
97bce1a
feat: make focus flash color and pattern configurable
teamleaderleo Sep 30, 2026
b69220a
fix: keep catalog default double blink and expose theme flash color
teamleaderleo Sep 30, 2026
8cb5ce2
feat: make typing pane flash configurable
teamleaderleo Oct 1, 2026
481f927
Merge remote-tracking branch 'upstream/main' into land/14894
teamleaderleo Oct 1, 2026
3f87103
feat: expose typing flash in Settings
teamleaderleo Oct 1, 2026
41ebae2
Merge remote-tracking branch 'upstream/calm-focus-pulse' into land/14894
teamleaderleo Oct 1, 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
Original file line number Diff line number Diff line change
Expand Up @@ -1137,12 +1137,24 @@ enum CmuxEmbeddedConfigSchema {
"default": true,
"description": "Flash the focused pane when requested."
},
"paneFlashDoubleBlink": {
"x-cmux-scopes": ["global"],
"type": "boolean",
"default": true,
"description": "Blink the pane flash twice instead of one short pulse."
},
"paneFlashThemeColor": {
"x-cmux-scopes": ["global"],
"type": "boolean",
"default": false,
"description": "Use the terminal theme foreground for flashes when paneFlashColor is unset. Unread rings remain cmux blue."
},
"paneFlashColor": {
"x-cmux-scopes": ["global"],
"$ref": "#/$defs/colorHexOrNull",
"default": null,
"descriptionKey": "schemaDescriptions.notifications.paneFlashColor",
"description": "Override the pane flash and unread ring color. Null keeps the built-in blue."
"description": "Override the pane flash and unread ring color. Null flashes in the terminal theme's foreground and keeps unread rings in the cmux accent."
},
"soundWhenFocused": {
"x-cmux-scopes": ["global"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ public enum NotificationDismissalContext: Sendable {
}
}

/// Whether a dismissal in this context flashes the pane. Typing means the
/// user is already working in that pane, so the cleared ring is the
/// acknowledgement; a flash under their cursor only adds noise.
public var flashesOnDismiss: Bool {
self != .terminalInteraction
}

/// Whether this context may clear a manually-set unread indicator.
public var canDismissManualUnreadIndicator: Bool {
self == .terminalInteraction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ public final class NotificationDismissalModel: NotificationDismissing {
host.storeClearFocusedReadIndicator(workspaceId: workspaceId, surfaceId: surfaceId)
}
}
if let targetPanelId {
if let targetPanelId, context.flashesOnDismiss {
if hasUnreadNotification || hasFocusedIndicator {
host.workspaceTriggerNotificationDismissFlash(workspaceId: workspaceId, panelId: targetPanelId)
} else if didDismissUnreadIndicator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,25 @@ struct NotificationDismissalModelTests {
#expect(!model.dismissNotificationOnDirectInteraction(workspaceId: workspaceId, surfaceId: panelId))
#expect(host.log.isEmpty)

// Terminal interaction clears it and triggers the indicator flash.
// Terminal interaction clears it without flashing the pane being typed in.
#expect(model.dismissNotificationOnTerminalInteraction(workspaceId: workspaceId, surfaceId: panelId))
let prefix = String(panelId.uuidString.prefix(4))
#expect(host.log == [
"panelClearManualUnread", "storeClearManualUnread",
"storeClearManualUnread:\(prefix)",
"clearFocusedRead:\(prefix)", "unreadIndicatorFlash",
"clearFocusedRead:\(prefix)",
])
}

@Test func typingDismissesAnUnreadNotificationWithoutFlashing() {
let (model, host, workspaceId, panelId) = makeModel()
host.unreadNotificationSurfaces = [panelId]

#expect(model.dismissNotificationOnTerminalInteraction(workspaceId: workspaceId, surfaceId: panelId))
let prefix = String(panelId.uuidString.prefix(4))
#expect(host.log == ["markRead:\(prefix)", "clearFocusedRead:\(prefix)"])
}

@Test func restoredUnreadNotClearedByPlainActiveFocus() {
let (model, host, workspaceId, panelId) = makeModel()
host.restoredPanelUnread = [panelId]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ public struct NotificationsCatalogSection: SettingCatalogSection {
userDefaultsKey: "notificationPaneFlashEnabled"
)

/// Blink the pane flash twice instead of one short pulse.
public let paneFlashDoubleBlink = DefaultsKey<Bool>(
id: "notifications.paneFlashDoubleBlink",
defaultValue: true,
userDefaultsKey: "notificationPaneFlashDoubleBlink"
)

public let paneFlashThemeColor = DefaultsKey<Bool>(
id: "notifications.paneFlashThemeColor",
defaultValue: false,
userDefaultsKey: "notificationPaneFlashThemeColor"
)

/// Stroke color of the attention ring and pane flash, as a `#RRGGBB` hex.
/// Empty (the default) uses the cmux accent (`app.accentColor`).
public let paneFlashColorHex = DefaultsKey<String>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .app, id: "show-menu-bar", title: String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), synonyms: "Show in Menu Bar notifications.showInMenuBar menubar menu bar status item tray extra"),
.init(section: .app, id: "unread-pane-ring", title: String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), synonyms: "Unread Pane Ring notifications.unreadPaneRing blue border unread ring notification pane outline"),
.init(section: .app, id: "pane-flash", title: String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), synonyms: "Pane Flash notifications.paneFlash flash blink highlight pane notification pulse"),
.init(section: .app, id: "pane-flash-double-blink", title: String(localized: "settings.notifications.paneFlashDoubleBlink.title", defaultValue: "Double Blink"), synonyms: "Double Blink notifications.paneFlashDoubleBlink pane flash blink twice pulse"),
.init(section: .app, id: "pane-flash-theme-color", title: String(localized: "settings.notifications.paneFlashThemeColor.title", defaultValue: "Use Theme Foreground"), synonyms: "Use Theme Foreground notifications.paneFlashThemeColor pane flash terminal theme foreground"),
.init(
section: .app,
id: "agent-permission-prompt",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7212,6 +7212,124 @@
}
}
}
},
"settings.notifications.paneFlashThemeColor.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use the terminal theme foreground for flashes; unread rings stay cmux blue."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Verwende die Vordergrundfarbe des Terminaldesigns für Blitze; ungelesene Ringe bleiben cmux-blau."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Utiliser le premier plan du thème du terminal pour les flashs ; les anneaux non lus restent bleus cmux."
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "استخدم مقدمة سمة الطرفية للومضات؛ تبقى حلقات عدم القراءة باللون الأزرق لـ cmux."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Usa el primer plano del tema de la terminal para los destellos; los anillos no leídos siguen siendo azules de cmux."
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "使用終端主題前景色顯示閃爍;未讀環維持 cmux 藍色。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "使用终端主题前景色显示闪烁;未读环保持 cmux 蓝色。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "플래시에 터미널 테마 전경색을 사용하며 읽지 않은 링은 cmux 파란색으로 유지합니다."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フラッシュにターミナルテーマの前景色を使い、未読リングは cmux ブルーのままにします。"
}
}
}
},
"settings.notifications.paneFlashThemeColor.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use Theme Foreground"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Vordergrund des Designs verwenden"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Utiliser le premier plan du thème"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "استخدام مقدمة السمة"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Usar el primer plano del tema"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "使用主題前景色"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "使用主题前景色"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "테마 전경색 사용"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "テーマの前景色を使う"
}
}
}
}
},
"version": "1.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CmuxSettings
import SwiftUI

/// **App** section — mirrors the legacy in-app section row-for-row
/// inside a single `SettingsCard`: Language, App Icon,
/// inside a single `SettingsCard`: Language, Appearance, App Icon,
/// New Workspace Placement, Inherit Working Directory, Minimal Mode,
/// Keep Workspace Open When Closing Last Surface, Focus Pane on
/// First Click, File Drops, Open Files With, Open Supported Files in
Expand All @@ -17,13 +17,14 @@ import SwiftUI
@MainActor
public struct AppSection: View {
private let catalog: SettingCatalog
private let defaultsStore: UserDefaultsSettingsStore
private let hostActions: SettingsHostActions

// Every bound value-model lives here as view state, constructed once
// and persisted across renders so the @Observable change tracking
// actually drives invalidation.
@State private var language: DefaultsValueModel<AppLanguage>
@State private var appearance: DefaultsValueModel<AppearanceMode>
@State private var accentColor: DefaultsValueModel<CmuxAccentColorMode>
@State private var appIcon: DefaultsValueModel<AppIconMode>
@State private var placement: DefaultsValueModel<WorkspacePlacement>
@State private var inheritDir: DefaultsValueModel<Bool>
Expand Down Expand Up @@ -55,6 +56,8 @@ public struct AppSection: View {
@State private var showInMenuBar: DefaultsValueModel<Bool>
@State private var paneRing: DefaultsValueModel<Bool>
@State private var paneFlash: DefaultsValueModel<Bool>
@State private var paneFlashDoubleBlink: DefaultsValueModel<Bool>
@State private var paneFlashThemeColor: DefaultsValueModel<Bool>
@State private var desktopNotifications: DesktopNotificationAuthorizationModel
@State private var agentPermissionPrompt: DefaultsValueModel<Bool>
@State private var agentTurnComplete: DefaultsValueModel<String>
Expand Down Expand Up @@ -91,10 +94,11 @@ public struct AppSection: View {
soundAgentCache: NotificationSoundAgentCache = NotificationSoundAgentCache()
) {
self.catalog = catalog
self.defaultsStore = defaultsStore
self.hostActions = hostActions
self.soundAgentCache = soundAgentCache
_language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.language))
_appearance = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appearance))
_accentColor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.accentColor))
_appIcon = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appIcon))
_placement = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.newWorkspacePlacement))
_inheritDir = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.workspaceInheritWorkingDirectory))
Expand Down Expand Up @@ -126,6 +130,8 @@ public struct AppSection: View {
_showInMenuBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.showInMenuBar))
_paneRing = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.unreadPaneRing))
_paneFlash = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.paneFlash))
_paneFlashDoubleBlink = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.paneFlashDoubleBlink))
_paneFlashThemeColor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.paneFlashThemeColor))
_desktopNotifications = State(initialValue: DesktopNotificationAuthorizationModel(hostActions: hostActions))
_agentPermissionPrompt = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.agentPermissionPrompt))
_agentTurnComplete = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.agentTurnComplete))
Expand Down Expand Up @@ -166,11 +172,10 @@ public struct AppSection: View {
SettingsSectionHeader(String(localized: "settings.section.app", defaultValue: "App"), section: .app)
.accessibilityIdentifier("SettingsAppSection")
mainCard
AppUpdatesCard(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions)
AppChannelSwitchCard(hostActions: hostActions)
}
.task {
startSettingsObservation([language, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, equalizeSplitsOnCreate, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundWhenFocused, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, warnCloseWorkspace, warnCloseWindow, hideCloseButton, renameSelects, paletteAllSurfaces])
startSettingsObservation([language, appearance, accentColor, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, equalizeSplitsOnCreate, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, paneFlashDoubleBlink, paneFlashThemeColor, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundWhenFocused, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, warnCloseWorkspace, warnCloseWindow, hideCloseButton, renameSelects, paletteAllSurfaces])
await soundAgentCache.loadIfNeeded { await hostActions.notificationSoundAgentOptions() }
if languageAtAppear == nil { languageAtAppear = language.current }; if telemetryAtAppear == nil { telemetryAtAppear = telemetry.current }
}
Expand Down Expand Up @@ -216,6 +221,31 @@ public struct AppSection: View {
}
SettingsCardDivider()

// Theme — three-up visual picker mirroring legacy
ThemePickerRow(
selectedMode: appearance.current,
onSelect: { appearance.set($0) }
)
.settingsSearchAnchors(["setting:app:appearance"])
SettingsCardDivider()

// Accent Color
SettingsCardRow(
configurationReview: .json("app.accentColor"),
String(localized: "settings.app.accentColor", defaultValue: "Accent Color"),
subtitle: String(localized: "settings.app.accentColor.subtitle", defaultValue: "Color of the selected workspace, attention ring, agent status, and other cmux highlights. System follows the macOS accent color."),
controlWidth: Self.columnWidth
) {
Picker("", selection: Binding(get: { accentColor.current }, set: { accentColor.set($0) })) {
Text(String(localized: "settings.app.accentColor.cmux", defaultValue: "cmux Blue")).tag(CmuxAccentColorMode.cmux)
Text(String(localized: "settings.app.accentColor.system", defaultValue: "System")).tag(CmuxAccentColorMode.system)
}
.labelsHidden()
.pickerStyle(.menu)
.accessibilityIdentifier("SettingsAccentColorPicker")
}
SettingsCardDivider()

// App Icon — three-up visual picker mirroring legacy
AppIconPickerRow(
selectedMode: appIcon.current,
Expand Down Expand Up @@ -709,6 +739,31 @@ public struct AppSection: View {
}
SettingsCardDivider()

// Pane Flash: Double Blink
SettingsCardRow(
configurationReview: .json("notifications.paneFlashDoubleBlink"),
String(localized: "settings.notifications.paneFlashDoubleBlink.title", defaultValue: "Double Blink"),
subtitle: String(localized: "settings.notifications.paneFlashDoubleBlink.subtitle", defaultValue: "Blink the pane flash twice instead of one short pulse.")
) {
Toggle("", isOn: Binding(get: { paneFlashDoubleBlink.current }, set: { paneFlashDoubleBlink.set($0) }))
.labelsHidden()
.controlSize(.small)
.disabled(!paneFlash.current)
}
SettingsCardDivider()

SettingsCardRow(
configurationReview: .json("notifications.paneFlashThemeColor"),
String(localized: "settings.notifications.paneFlashThemeColor.title", defaultValue: "Use Theme Foreground"),
subtitle: String(localized: "settings.notifications.paneFlashThemeColor.subtitle", defaultValue: "Use the terminal theme foreground for flashes; unread rings stay cmux blue.")
) {
Toggle("", isOn: Binding(get: { paneFlashThemeColor.current }, set: { paneFlashThemeColor.set($0) }))
.labelsHidden()
.controlSize(.small)
.disabled(!paneFlash.current)
}
SettingsCardDivider()

// Agent: Needs Permission
SettingsCardRow(
configurationReview: .json("notifications.agentPermissionPrompt"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ struct SettingsRowAnchorResolutionTests {
"notifications.command",
"notifications.dockBadge",
"notifications.paneFlash",
"notifications.paneFlashDoubleBlink",
"notifications.paneFlashThemeColor",
"notifications.paneFlashColor",
"notifications.showInMenuBar",
"notifications.sound",
Expand Down
Loading