diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index ac09e56a9ff8..96771508854a 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1163,12 +1163,30 @@ 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." + }, + "paneFlashOnTyping": { + "x-cmux-scopes": ["global"], + "type": "boolean", + "default": true, + "description": "Flash the pane when terminal typing dismisses its notification." + }, + "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"], diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalContext.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalContext.swift index fe79e58b024f..457ec7e39a6e 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalContext.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalContext.swift @@ -22,6 +22,25 @@ public enum NotificationDismissalContext: Sendable { } } + /// Whether a dismissal in this context flashes the pane. + /// + /// - Parameter flashOnTyping: Whether terminal typing should flash the + /// pane after it dismisses a notification. + public func flashesOnDismiss(flashOnTyping: Bool) -> Bool { + switch self { + case .terminalInteraction: + return flashOnTyping + case .activeFocus, .explicitWorkspaceResume, .directInteraction: + return true + } + } + + /// Whether a dismissal in this context flashes the pane using the calmer + /// default for terminal typing. + public var flashesOnDismiss: Bool { + flashesOnDismiss(flashOnTyping: false) + } + /// Whether this context may clear a manually-set unread indicator. public var canDismissManualUnreadIndicator: Bool { self == .terminalInteraction diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalHosting.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalHosting.swift index ce08e85d5805..a7343a564500 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalHosting.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalHosting.swift @@ -60,6 +60,11 @@ public protocol NotificationDismissalHosting: AnyObject { /// default) the legacy workspace-visibility withdraw is preserved. var suppressOnlyFocusedSurface: Bool { get } + /// Whether terminal typing should flash the pane after dismissing its + /// notification. The app setting defaults to the legacy flashing behavior; + /// turning it off gives typing the calmer dismissal feedback. + var paneFlashOnTyping: Bool { get } + // MARK: Workspace indicator reads /// Whether the panel carries a manually-set unread indicator. diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalModel.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalModel.swift index 270b27760081..5f0313156274 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalModel.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDismissalModel.swift @@ -240,7 +240,7 @@ public final class NotificationDismissalModel: NotificationDismissing { host.storeClearFocusedReadIndicator(workspaceId: workspaceId, surfaceId: surfaceId) } } - if let targetPanelId { + if let targetPanelId, context.flashesOnDismiss(flashOnTyping: host.paneFlashOnTyping) { if hasUnreadNotification || hasFocusedIndicator { host.workspaceTriggerNotificationDismissFlash(workspaceId: workspaceId, panelId: targetPanelId) } else if didDismissUnreadIndicator { diff --git a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDismissalModelTests.swift b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDismissalModelTests.swift index f60f7aa64426..5ba23b95bfb7 100644 --- a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDismissalModelTests.swift +++ b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDismissalModelTests.swift @@ -13,6 +13,7 @@ private final class FakeHost: NotificationDismissalHosting { var focusedPanelIds: [UUID: UUID] = [:] var focusedSurfaceIds: [UUID: UUID] = [:] var suppressOnlyFocusedSurface = false + var paneFlashOnTyping = false var panelIdsBySurface: [UUID: UUID] = [:] var manualPanelUnread: Set = [] var restoredPanelUnread: Set = [] @@ -250,16 +251,35 @@ 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 typingCanFlashWhenEnabled() { + let (model, host, workspaceId, panelId) = makeModel() + host.paneFlashOnTyping = true + host.unreadNotificationSurfaces = [panelId] + + #expect(model.dismissNotificationOnTerminalInteraction(workspaceId: workspaceId, surfaceId: panelId)) + let prefix = String(panelId.uuidString.prefix(4)) + #expect(host.log == ["markRead:\(prefix)", "clearFocusedRead:\(prefix)", "notificationFlash"]) + } + @Test func restoredUnreadNotClearedByPlainActiveFocus() { let (model, host, workspaceId, panelId) = makeModel() host.restoredPanelUnread = [panelId] diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift index 4f775a196810..4f6784fe88a6 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift @@ -26,6 +26,26 @@ public struct NotificationsCatalogSection: SettingCatalogSection { userDefaultsKey: "notificationPaneFlashEnabled" ) + /// Blink the pane flash twice instead of one short pulse. + public let paneFlashDoubleBlink = DefaultsKey( + id: "notifications.paneFlashDoubleBlink", + defaultValue: true, + userDefaultsKey: "notificationPaneFlashDoubleBlink" + ) + + /// Flash the pane when terminal typing dismisses its notification. + public let paneFlashOnTyping = DefaultsKey( + id: "notifications.paneFlashOnTyping", + defaultValue: true, + userDefaultsKey: "notificationPaneFlashOnTyping" + ) + + public let paneFlashThemeColor = DefaultsKey( + 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( diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 0fb0699e4c12..f19db211de71 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -146,6 +146,9 @@ 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-on-typing", title: String(localized: "settings.notifications.paneFlashOnTyping.title", defaultValue: "Flash While Typing"), synonyms: "Flash While Typing notifications.paneFlashOnTyping pane flash typing terminal interaction notification 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", diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings index 2b3c94b79751..2970f4d325ae 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -13338,6 +13338,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" diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index 7f797cb499a8..e8ba35dcdbcd 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -54,6 +54,9 @@ public struct AppSection: View { @State private var showInMenuBar: DefaultsValueModel @State private var paneRing: DefaultsValueModel @State private var paneFlash: DefaultsValueModel + @State private var paneFlashDoubleBlink: DefaultsValueModel + @State private var paneFlashOnTyping: DefaultsValueModel + @State private var paneFlashThemeColor: DefaultsValueModel @State private var desktopNotifications: DesktopNotificationAuthorizationModel @State private var agentPermissionPrompt: DefaultsValueModel @State private var agentTurnComplete: DefaultsValueModel @@ -124,6 +127,9 @@ 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)) + _paneFlashOnTyping = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.notifications.paneFlashOnTyping)) + _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)) @@ -167,7 +173,7 @@ public struct AppSection: View { 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, 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, paneFlashOnTyping, 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 } } @@ -706,6 +712,43 @@ 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.paneFlashOnTyping"), + String(localized: "settings.notifications.paneFlashOnTyping.title", defaultValue: "Flash While Typing"), + subtitle: String(localized: "settings.notifications.paneFlashOnTyping.subtitle", defaultValue: "Flash the pane when typing dismisses its notification.") + ) { + Toggle("", isOn: Binding(get: { paneFlashOnTyping.current }, set: { paneFlashOnTyping.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"), diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 84f91119553c..4a552882aaa0 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -108,6 +108,9 @@ struct SettingsRowAnchorResolutionTests { "notifications.command", "notifications.dockBadge", "notifications.paneFlash", + "notifications.paneFlashDoubleBlink", + "notifications.paneFlashOnTyping", + "notifications.paneFlashThemeColor", "notifications.paneFlashColor", "notifications.showInMenuBar", "notifications.sound", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 79d6e43c5ac8..86389375ff53 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -606424,6 +606424,396 @@ } } } + }, + "settings.notifications.paneFlashDoubleBlink.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Double Blink" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض مزدوج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doppeltes Blinken" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Parpadeo doble" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Double clignotement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2回点滅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "두 번 깜빡임" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Подвійне блимання" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "闪烁两次" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閃爍兩次" + } + } + } + }, + "settings.notifications.paneFlashDoubleBlink.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blink the pane flash twice instead of one short pulse." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض الجزء مرتين بدلاً من نبضة قصيرة واحدة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Den Bereich zweimal aufblinken lassen statt eines kurzen Pulses." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hacer parpadear el panel dos veces en lugar de un pulso breve." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Faire clignoter le volet deux fois au lieu d’une brève pulsation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインの点滅を短い1回ではなく2回にします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "패인 깜빡임을 짧은 한 번 대신 두 번 표시합니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Блимати панеллю двічі замість одного короткого імпульсу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗格闪烁两次,而不是一次短暂的脉冲。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "窗格閃爍兩次,而不是一次短暫的脈衝。" + } + } + } + }, + "settings.notifications.paneFlashOnTyping.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flash While Typing" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض أثناء الكتابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Tippen blinken" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Parpadeo al escribir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clignoter pendant la saisie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力中に点滅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 중 깜빡임" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Блимати під час введення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入时闪烁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入時閃爍" + } + } + } + }, + "settings.notifications.paneFlashOnTyping.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flash the pane when typing dismisses its notification." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وميض اللوحة عند رفض إشعارها أثناء الكتابة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Fenster aufblitzen lassen, wenn Tippen die Benachrichtigung schließt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hacer parpadear el panel cuando escribir descarta su notificación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Faire clignoter le volet lorsque la saisie efface sa notification." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力で通知を消したときにペインを点滅させます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력으로 알림을 지울 때 패인을 깜빡입니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Блимати панеллю, коли введення закриває її сповіщення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入文字清除通知时闪烁窗格。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入文字清除通知時閃爍窗格。" + } + } + } + }, + "settings.search.alias.setting.app.pane-flash-on-typing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse وميض أثناء الكتابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse blinken beim tippen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse destello al escribir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse flash pendant la saisie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse 入力中に点滅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse 입력 중 깜빡임" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse спалах під час введення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse 输入时闪烁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse 輸入時閃爍" + } + } + } + }, + "settings.search.alias.setting.app.pane-flash-double-blink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse وميض مزدوج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse doppeltes blinken" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse parpadeo doble" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse double clignotement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse 2回点滅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse 두 번 깜빡임" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse подвійне блимання" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse 闪烁两次" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "notifications.paneFlashDoubleBlink pane flash double blink twice pulse 閃爍兩次" + } + } + } } }, "version": "1.0" diff --git a/Sources/CmuxSettingsFileStore+SupportedPaths.swift b/Sources/CmuxSettingsFileStore+SupportedPaths.swift index c81f09ce48d9..2f8d6ac22c18 100644 --- a/Sources/CmuxSettingsFileStore+SupportedPaths.swift +++ b/Sources/CmuxSettingsFileStore+SupportedPaths.swift @@ -74,6 +74,9 @@ extension CmuxSettingsFileStore { "notifications.showInMenuBar", "notifications.unreadPaneRing", "notifications.paneFlash", + "notifications.paneFlashDoubleBlink", + "notifications.paneFlashOnTyping", + "notifications.paneFlashThemeColor", "notifications.paneFlashColor", "notifications.sound", "notifications.soundWhenFocused", diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 7ba85203c73b..da9a0e8194ee 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -238,6 +238,9 @@ enum NotificationSettingsFileMapping { .init(jsonKey: "showInMenuBar", defaultsKey: MenuBarExtraSettings.showInMenuBarKey), .init(jsonKey: "unreadPaneRing", defaultsKey: NotificationPaneRingSettings.enabledKey), .init(jsonKey: "paneFlash", defaultsKey: NotificationPaneFlashSettings.enabledKey), + .init(jsonKey: "paneFlashDoubleBlink", defaultsKey: NotificationPaneFlashSettings.doubleBlinkKey), + .init(jsonKey: "paneFlashOnTyping", defaultsKey: NotificationPaneFlashSettings.onTypingKey), + .init(jsonKey: "paneFlashThemeColor", defaultsKey: NotificationPaneFlashSettings.themeColorKey), .init( jsonKey: "soundWhenFocused", defaultsKey: notifications.soundWhenFocused.userDefaultsKey diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 221b5ab5c3c4..8909575ec131 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -429,6 +429,28 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: NotificationPaneFlashSettings.defaultEnabled, defaultsKey: NotificationPaneFlashSettings.enabledKey ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "paneFlashDoubleBlink", + settingsKey: "notifications.paneFlashDoubleBlink", + title: { + String(localized: "settings.notifications.paneFlashDoubleBlink.title", defaultValue: "Double Blink") + }, + sectionTitle: app, + keywords: ["notifications.paneFlashDoubleBlink", "pane", "flash", "double", "blink", "pulse"], + defaultValue: NotificationPaneFlashSettings.defaultDoubleBlink, + defaultsKey: NotificationPaneFlashSettings.doubleBlinkKey + ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "paneFlashOnTyping", + settingsKey: "notifications.paneFlashOnTyping", + title: { + String(localized: "settings.notifications.paneFlashOnTyping.title", defaultValue: "Flash While Typing") + }, + sectionTitle: app, + keywords: ["notifications.paneFlashOnTyping", "pane", "flash", "typing", "notification"], + defaultValue: NotificationPaneFlashSettings.defaultOnTyping, + defaultsKey: NotificationPaneFlashSettings.onTypingKey + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "sendAnonymousTelemetry", settingsKey: "app.sendAnonymousTelemetry", diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b63bd4dcccba..d9d895ac8ef4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -906,10 +906,31 @@ struct ContentView: View { @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints @LiveSetting(\.customSidebars.renderer) private var customSidebarRenderer @LiveSetting(\.notifications.paneFlashColorHex) private var paneFlashColorHex + @AppStorage("notificationPaneFlashThemeColor") private var paneFlashThemeColor = false /// Resolved cmux accent, seeded from the app delegate's observer and /// updated from its change notification. This view is the window root, /// so it cannot read the accent from its own environment modifier. @State private var cmuxAccent = AppDelegate.shared?.accentColor ?? CmuxAccentColor() + /// Terminal theme foreground, which colors pane flashes when no flash + /// color is configured. Updated from the default appearance notification. + @State private var terminalThemeForeground = GhosttyApp.shared.defaultForegroundColor + + private var resolvedWorkspaceAttentionColor: WorkspaceAttentionColor { + resolveWorkspaceAttentionColor() + } + + private func resolveWorkspaceAttentionColor( + configuredHex: String?? = nil, + accent: CmuxAccentColor? = nil, + themeForeground: NSColor? = nil + ) -> WorkspaceAttentionColor { + WorkspaceAttentionColor( + configuredHex: configuredHex ?? paneFlashColorHex, + accent: accent ?? cmuxAccent, + themeForeground: themeForeground ?? terminalThemeForeground, + useThemeForeground: paneFlashThemeColor + ) + } /// Canonical sidebar width, deliberately NOT observed by ContentView: /// divider ticks re-evaluate only the SidebarWidthReader wrappers that /// consume the width, never this body. All reads/writes outside view diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3b51222659e1..7b1d3d84c77e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10357,6 +10357,7 @@ final class GhosttySurfaceScrollView: NSView { private var lastFlashStyle: FlashStyle = .navigation private var workspaceAttentionColor = WorkspaceAttentionColor(configuredHex: nil) private var workspaceAttentionNSColor = WorkspaceAttentionColor(configuredHex: nil).nsColor + private var workspaceAttentionFlashNSColor = WorkspaceAttentionColor(configuredHex: nil).flashNSColor private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView @@ -10684,7 +10685,7 @@ final class GhosttySurfaceScrollView: NSView { flashOverlayView.layer?.masksToBounds = false flashOverlayView.autoresizingMask = [.width, .height] let flashStyle = WorkspaceAttentionCoordinator.flashStyle(for: .navigation) - let flashColor = WorkspaceAttentionColor(configuredHex: nil).nsColor + let flashColor = WorkspaceAttentionColor(configuredHex: nil).flashNSColor flashLayer.fillColor = NSColor.clear.cgColor flashLayer.strokeColor = flashColor.cgColor flashLayer.lineWidth = NotificationRingMetrics.lineWidth @@ -11577,6 +11578,7 @@ final class GhosttySurfaceScrollView: NSView { private func applyWorkspaceAttentionNSColor() { workspaceAttentionNSColor = workspaceAttentionColor.nsColor + workspaceAttentionFlashNSColor = workspaceAttentionColor.flashNSColor CATransaction.begin() CATransaction.setDisableActions(true) @@ -12098,11 +12100,12 @@ final class GhosttySurfaceScrollView: NSView { updateFlashAppearance(style: style) flashLayer.removeAllAnimations() flashLayer.opacity = 0 + let pattern = FocusFlashPattern.current let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } - animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } - animation.duration = FocusFlashPattern.duration - animation.timingFunctions = FocusFlashPattern.curves.map { curve in + animation.values = pattern.values.map { NSNumber(value: $0) } + animation.keyTimes = pattern.keyTimes.map { NSNumber(value: $0) } + animation.duration = pattern.duration + animation.timingFunctions = pattern.curves.map { curve in switch curve { case .easeIn: return CAMediaTimingFunction(name: .easeIn) @@ -13714,8 +13717,8 @@ final class GhosttySurfaceScrollView: NSView { private func updateFlashAppearance(style: FlashStyle) { let presentation = Self.flashPresentation(for: style) - flashLayer.strokeColor = workspaceAttentionNSColor.cgColor - flashLayer.shadowColor = workspaceAttentionNSColor.cgColor + flashLayer.strokeColor = workspaceAttentionFlashNSColor.cgColor + flashLayer.shadowColor = workspaceAttentionFlashNSColor.cgColor flashLayer.shadowOpacity = Float(presentation.glowOpacity) flashLayer.shadowRadius = presentation.glowRadius } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index d961d9db5603..aa717444e3f3 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -131,6 +131,9 @@ extension CmuxSettingsFileStore { "showInMenuBar": MenuBarExtraSettings.defaultShowInMenuBar, "unreadPaneRing": NotificationPaneRingSettings.defaultEnabled, "paneFlash": NotificationPaneFlashSettings.defaultEnabled, + "paneFlashDoubleBlink": NotificationPaneFlashSettings.defaultDoubleBlink, + "paneFlashOnTyping": NotificationPaneFlashSettings.defaultOnTyping, + "paneFlashThemeColor": NotificationPaneFlashSettings.defaultThemeColor, "paneFlashColor": NSNull(), "sound": NotificationSoundSettings.defaultValue, "customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath, diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 14c25a14737a..4a105ad16247 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1956,9 +1956,9 @@ struct BrowserPanelView: View { private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration - focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + focusFlashOpacity = FocusFlashPattern.current.values.first ?? 0 - for segment in FocusFlashPattern.segments { + for segment in FocusFlashPattern.current.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { diff --git a/Sources/Panels/CustomSidebarPanelView.swift b/Sources/Panels/CustomSidebarPanelView.swift index c8d402ef7231..a4ad2ed249f8 100644 --- a/Sources/Panels/CustomSidebarPanelView.swift +++ b/Sources/Panels/CustomSidebarPanelView.swift @@ -147,10 +147,10 @@ struct CustomSidebarPanelView: View { if shouldAnimateFocusFlash, let focusFlashStartedAt { TimelineView(TmuxWorkspacePaneFlashTimelineSchedule(startDate: focusFlashStartedAt)) { timeline in WorkspaceAttentionFlashRingView( - opacity: FocusFlashPattern.opacity(at: timeline.date.timeIntervalSince(focusFlashStartedAt)) + opacity: FocusFlashPattern.current.opacity(at: timeline.date.timeIntervalSince(focusFlashStartedAt)) ) .onChange(of: timeline.date) { _, date in - if date.timeIntervalSince(focusFlashStartedAt) >= FocusFlashPattern.duration { + if date.timeIntervalSince(focusFlashStartedAt) >= FocusFlashPattern.current.duration { completedFocusFlashStartedAt = focusFlashStartedAt } } @@ -163,6 +163,6 @@ struct CustomSidebarPanelView: View { private var shouldAnimateFocusFlash: Bool { guard let focusFlashStartedAt else { return false } guard completedFocusFlashStartedAt != focusFlashStartedAt else { return false } - return Date() <= focusFlashStartedAt.addingTimeInterval(FocusFlashPattern.duration) + return Date() <= focusFlashStartedAt.addingTimeInterval(FocusFlashPattern.current.duration) } } diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 24c9163daa25..6af010a6103e 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1880,9 +1880,9 @@ struct FilePreviewPanelView: View { private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration - focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + focusFlashOpacity = FocusFlashPattern.current.values.first ?? 0 - for segment in FocusFlashPattern.segments { + for segment in FocusFlashPattern.current.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 831aeac29b7c..ea72e2a3b0ca 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -270,9 +270,9 @@ struct MarkdownPanelView: View { private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration - focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + focusFlashOpacity = FocusFlashPattern.current.values.first ?? 0 - for segment in FocusFlashPattern.segments { + for segment in FocusFlashPattern.current.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index c3b86fe519a2..5bbb18a6e699 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -252,15 +252,37 @@ struct FocusFlashSegment: Equatable { let curve: FocusFlashCurve } -enum FocusFlashPattern { - static let values: [Double] = [0, 1, 0, 1, 0] - static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] - static let duration: TimeInterval = 0.9 - static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] +/// The attention flash shape. One short pulse by default: enough to say where +/// focus or attention landed without replaying a blink on every move between +/// panes. `notifications.paneFlashDoubleBlink` restores the older double blink. +struct FocusFlashPattern: Equatable { + let values: [Double] + let keyTimes: [Double] + let duration: TimeInterval + let curves: [FocusFlashCurve] + + static let pulse = FocusFlashPattern( + values: [0, 1, 0], + keyTimes: [0, 0.3, 1], + duration: 0.6, + curves: [.easeOut, .easeIn] + ) + static let doubleBlink = FocusFlashPattern( + values: [0, 1, 0, 1, 0], + keyTimes: [0, 0.25, 0.5, 0.75, 1], + duration: 0.9, + curves: [.easeOut, .easeIn, .easeOut, .easeIn] + ) + + /// The shape the user has chosen, read when a flash starts. + static var current: FocusFlashPattern { + NotificationPaneFlashSettings.usesDoubleBlink() ? doubleBlink : pulse + } + static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) - static var segments: [FocusFlashSegment] { + var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) return (0.. Double { + func opacity(at elapsed: TimeInterval) -> Double { guard elapsed >= 0, elapsed <= duration else { return 0 } - for index in 0.. endTime { @@ -286,7 +308,7 @@ enum FocusFlashPattern { let segmentDuration = max(endTime - startTime, 0.0001) let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration)) - let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index]) + let curvedProgress = Self.interpolatedProgress(rawProgress, curve: curves[index]) let startOpacity = values[index] let endOpacity = values[index + 1] return startOpacity + ((endOpacity - startOpacity) * curvedProgress) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 8d757c4e264b..61dc4ab9cf2e 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -822,7 +822,7 @@ final class TerminalPanel: Panel, ObservableObject { now < attentionFlashActiveUntil { return } - attentionFlashActiveUntil = now + FocusFlashPattern.duration + attentionFlashActiveUntil = now + FocusFlashPattern.current.duration switch TmuxOverlayExperimentSettings.target() { case .bonsplitPane: diff --git a/Sources/Panels/WorkspaceAttentionColor.swift b/Sources/Panels/WorkspaceAttentionColor.swift index dfc1c6e32e19..2468f3fc6b9d 100644 --- a/Sources/Panels/WorkspaceAttentionColor.swift +++ b/Sources/Panels/WorkspaceAttentionColor.swift @@ -4,6 +4,9 @@ import SwiftUI /// The resolved color shared by pane flashes and unread notification rings. /// +/// A configured color paints both. Without one, both signals use the existing +/// cmux accent, preserving the established blue default. +/// /// The setting store remains the only owner of the configured string. This /// value validates one immutable snapshot before it reaches a renderer, so /// AppKit layers and SwiftUI canvases never read ambient defaults or parse the @@ -12,21 +15,41 @@ struct WorkspaceAttentionColor: Hashable, Sendable { private let rgb: UInt32? /// The resolved cmux accent used when no valid color is configured. private let accent: CmuxAccentColor - - init(configuredHex: String?, accent: CmuxAccentColor = CmuxAccentColor()) { + private let themeForegroundHex: String? + private let useThemeForeground: Bool + init( + configuredHex: String?, + accent: CmuxAccentColor = CmuxAccentColor(), + themeForeground: NSColor? = nil, + useThemeForeground: Bool = false + ) { self.rgb = Self.strictRGB(configuredHex) self.accent = accent + self.themeForegroundHex = themeForeground?.hexString() + self.useThemeForeground = useThemeForeground } var nsColor: NSColor { guard let rgb else { return WorkspaceAttentionCoordinator.notificationRingStyle.accent.strokeColor(accent: accent) } - return NSColor( + return Self.color(rgb: rgb, alpha: 1) + } + + /// The pane flash keeps the configured color or the existing cmux accent. + var flashNSColor: NSColor { + guard rgb == nil, useThemeForeground, + let themeForegroundHex, + let themeForeground = NSColor(hex: themeForegroundHex) else { return nsColor } + return themeForeground + } + + private static func color(rgb: UInt32, alpha: CGFloat) -> NSColor { + NSColor( red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, - alpha: 1 + alpha: alpha ) } diff --git a/Sources/Panels/WorkspaceAttentionFlashRingView.swift b/Sources/Panels/WorkspaceAttentionFlashRingView.swift index 3f1394baa2a8..f8b9908b60fc 100644 --- a/Sources/Panels/WorkspaceAttentionFlashRingView.swift +++ b/Sources/Panels/WorkspaceAttentionFlashRingView.swift @@ -8,7 +8,7 @@ struct WorkspaceAttentionFlashRingView: View { var body: some View { let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason) - let color = Color(nsColor: workspaceAttentionColor.nsColor) + let color = Color(nsColor: workspaceAttentionColor.flashNSColor) RoundedRectangle(cornerRadius: CGFloat(FocusFlashPattern.ringCornerRadius)) .stroke(color.opacity(opacity), lineWidth: PanelOverlayRingMetrics.lineWidth) diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 837acda0b347..3867ca9665c3 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -287,9 +287,9 @@ struct RightSidebarToolPanelView: View { private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration - focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + focusFlashOpacity = FocusFlashPattern.current.values.first ?? 0 - for segment in FocusFlashPattern.segments { + for segment in FocusFlashPattern.current.segments { DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { guard focusFlashAnimationGeneration == generation else { return } withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 9ec7cb9e4346..91767d5018de 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -93,6 +93,8 @@ enum SettingsSearchAliasIndex { "app:show-menu-bar": localized("settings.search.alias.setting.app.show-menu-bar", defaultValue: "notifications.showInMenuBar menubar menu bar status item tray extra"), "app:unread-pane-ring": localized("settings.search.alias.setting.app.unread-pane-ring", defaultValue: "notifications.unreadPaneRing blue border unread ring notification pane outline"), "app:pane-flash": localized("settings.search.alias.setting.app.pane-flash", defaultValue: "notifications.paneFlash flash blink highlight pane notification pulse"), + "app:pane-flash-double-blink": localized("settings.search.alias.setting.app.pane-flash-double-blink", defaultValue: "notifications.paneFlashDoubleBlink pane flash double blink twice pulse"), + "app:pane-flash-on-typing": localized("settings.search.alias.setting.app.pane-flash-on-typing", defaultValue: "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse"), "app:desktop-notifications": localized("settings.search.alias.setting.app.desktop-notifications", defaultValue: "macos desktop notifications system settings permission alerts notify test"), "app:notification-sound": localized("settings.search.alias.setting.app.notification-sound", defaultValue: "notifications.sound notifications.customSoundFilePath sound audio alert chime beep custom file wav mp3 caf aiff"), "app:notification-sound-overrides": localized("settings.search.alias.setting.app.notification-sound-overrides", defaultValue: "notifications.soundOverrides per-agent agent sound turn done needs input permission error stalled custom file"), diff --git a/Sources/SettingsSearchIndex.swift b/Sources/SettingsSearchIndex.swift index 801b9f5292da..38f4a43c2a7e 100644 --- a/Sources/SettingsSearchIndex.swift +++ b/Sources/SettingsSearchIndex.swift @@ -50,6 +50,9 @@ enum SettingsSearchIndex { setting(.app, "show-menu-bar", String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), "menu extra status item"), setting(.app, "unread-pane-ring", String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), "notification blue ring pane"), setting(.app, "pane-flash", String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), "notification flash highlight"), + setting(.app, "pane-flash-theme-color", String(localized: "settings.notifications.paneFlashThemeColor.title", defaultValue: "Use Theme Foreground"), "notification flash terminal theme foreground blue ring"), + setting(.app, "pane-flash-double-blink", String(localized: "settings.notifications.paneFlashDoubleBlink.title", defaultValue: "Double Blink"), "pane flash blink twice pulse"), + setting(.app, "pane-flash-on-typing", String(localized: "settings.notifications.paneFlashOnTyping.title", defaultValue: "Flash While Typing"), "notifications.paneFlashOnTyping pane flash typing terminal interaction notification pulse"), setting(.app, "desktop-notifications", String(localized: "settings.notifications.desktop", defaultValue: "Desktop Notifications"), "permission alerts test notification"), setting(.app, "notification-sound", String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), "custom sound alert audio"), setting( @@ -272,6 +275,8 @@ enum SettingsSearchIndex { "notifications.showInMenuBar": settingID(for: .app, idSuffix: "show-menu-bar"), "notifications.unreadPaneRing": settingID(for: .app, idSuffix: "unread-pane-ring"), "notifications.paneFlash": settingID(for: .app, idSuffix: "pane-flash"), + "notifications.paneFlashThemeColor": settingID(for: .app, idSuffix: "pane-flash-theme-color"), + "notifications.paneFlashDoubleBlink": settingID(for: .app, idSuffix: "pane-flash-double-blink"), "notifications.sound": settingID(for: .app, idSuffix: "notification-sound"), "notifications.customSoundFilePath": settingID(for: .app, idSuffix: "notification-sound"), "notifications.soundOverrides": settingID(for: .app, idSuffix: "notification-sound-overrides"), diff --git a/Sources/TabManager+NotificationDismissalHosting.swift b/Sources/TabManager+NotificationDismissalHosting.swift index fef4c5bbdf30..d494f6dd15e9 100644 --- a/Sources/TabManager+NotificationDismissalHosting.swift +++ b/Sources/TabManager+NotificationDismissalHosting.swift @@ -65,6 +65,11 @@ extension TabManager: NotificationDismissalHosting { .value(for: Self.notificationsSettings.suppressOnlyFocusedSurface) } + var paneFlashOnTyping: Bool { + UserDefaultsSettingsClient(defaults: .standard) + .value(for: Self.notificationsSettings.paneFlashOnTyping) + } + func panelId(forSurfaceOrPanelId surfaceId: UUID, in workspaceId: UUID) -> UUID? { if let dock = DockSplitStore.liveStore(containingPanel: surfaceId), dock.workspaceId == workspaceId, diff --git a/Sources/TerminalNotificationStoreSettings.swift b/Sources/TerminalNotificationStoreSettings.swift index c9eaa47da1a6..6338d145ee29 100644 --- a/Sources/TerminalNotificationStoreSettings.swift +++ b/Sources/TerminalNotificationStoreSettings.swift @@ -24,6 +24,22 @@ enum NotificationPaneRingSettings { enum NotificationPaneFlashSettings { static let enabledKey = "notificationPaneFlashEnabled" static let defaultEnabled = true + static let doubleBlinkKey = "notificationPaneFlashDoubleBlink" + static let defaultDoubleBlink = true + static let onTypingKey = "notificationPaneFlashOnTyping" + static let defaultOnTyping = true + static let themeColorKey = "notificationPaneFlashThemeColor" + static let defaultThemeColor = false + + /// Whether the pane flash blinks twice instead of one short pulse. + static func usesDoubleBlink(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: doubleBlinkKey) as? Bool ?? defaultDoubleBlink + } + + /// Whether terminal typing should flash the pane after dismissing a notification. + static func flashesOnTyping(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: onTypingKey) as? Bool ?? defaultOnTyping + } static func isEnabled(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: enabledKey) == nil { diff --git a/Sources/TmuxWorkspacePaneOverlayView.swift b/Sources/TmuxWorkspacePaneOverlayView.swift index e97d3d56eb2c..0e1c0abe1487 100644 --- a/Sources/TmuxWorkspacePaneOverlayView.swift +++ b/Sources/TmuxWorkspacePaneOverlayView.swift @@ -25,7 +25,7 @@ struct TmuxWorkspacePaneOverlayView: View { TimelineView(TmuxWorkspacePaneFlashTimelineSchedule(startDate: flashStartedAt)) { timeline in overlayCanvas(timelineDate: timeline.date, attentionColor: attentionColor) .onChange(of: timeline.date) { _, date in - if date.timeIntervalSince(flashStartedAt) >= FocusFlashPattern.duration { + if date.timeIntervalSince(flashStartedAt) >= FocusFlashPattern.current.duration { completedFlashStartedAt = flashStartedAt } } @@ -42,7 +42,7 @@ struct TmuxWorkspacePaneOverlayView: View { let flashStartedAt else { return false } guard completedFlashStartedAt != flashStartedAt, ringPath(for: flashRect) != nil else { return false } - return Date() <= flashStartedAt.addingTimeInterval(FocusFlashPattern.duration) + return Date() <= flashStartedAt.addingTimeInterval(FocusFlashPattern.current.duration) } /// Clips the active border to the drawable canvas so its bottom and right @@ -66,14 +66,14 @@ struct TmuxWorkspacePaneOverlayView: View { let flashStartedAt, let timelineDate else { return } let elapsed = timelineDate.timeIntervalSince(flashStartedAt) - let opacity = FocusFlashPattern.opacity(at: elapsed) + let opacity = FocusFlashPattern.current.opacity(at: elapsed) guard opacity > 0.001 else { return } drawFlashRing( in: &context, rect: flashRect, opacity: opacity, reason: flashReason ?? .notificationArrival, - color: attentionColor + color: Color(nsColor: workspaceAttentionColor.flashNSColor) ) } } @@ -155,7 +155,7 @@ struct TmuxWorkspacePaneFlashTimelineSchedule: TimelineSchedule { let interval = mode == .lowFrequency ? 1.0 / 10.0 : 1.0 / 60.0 return Entries( nextDate: firstDate, - endDate: startDate.addingTimeInterval(FocusFlashPattern.duration), + endDate: startDate.addingTimeInterval(FocusFlashPattern.current.duration), interval: interval ) } diff --git a/cmuxTests/KeyboardShortcutModifierHoldHintsSettingsFileTests.swift b/cmuxTests/KeyboardShortcutModifierHoldHintsSettingsFileTests.swift index 7b600bd8e7bb..3ac5e955403a 100644 --- a/cmuxTests/KeyboardShortcutModifierHoldHintsSettingsFileTests.swift +++ b/cmuxTests/KeyboardShortcutModifierHoldHintsSettingsFileTests.swift @@ -220,6 +220,28 @@ struct PaneAttentionColorTests { ) } + @Test + func flashFollowsThemeForegroundWhenUnset() { + let accent = CmuxAccentColor() + let color = WorkspaceAttentionColor( + configuredHex: nil, + accent: accent, + themeForeground: NSColor(hex: "#CDD6F4"), + useThemeForeground: true + ) + #expect(color.flashNSColor.hexString() == "#CDD6F4") + #expect(color.flashNSColor.alphaComponent == 1) + // Unread rings keep the accent. + #expect(color.nsColor.hexString() == accent.dynamicNSColor.hexString()) + } + + @Test + func configuredHexOverridesThemeForegroundForFlash() { + let color = WorkspaceAttentionColor(configuredHex: "#ff69b4", themeForeground: .white, useThemeForeground: true) + #expect(color.flashNSColor.hexString() == "#FF69B4") + #expect(color.flashNSColor.alphaComponent == 1) + } + @Test(arguments: ["not-a-color", "#FFZZZZ", "FF69B4", "#FF69B4AA"]) func rejectsValuesOutsideSchema(configuredHex: String) { assertUsesInjectedAccent(configuredHex: configuredHex) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 8eca6a7f7f1d..7eaee4ac07bc 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -3313,9 +3313,13 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } - GhosttySurfaceScrollView.resetFlashCounts() AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) + // Let the runtime surface come up and the workspace's startup focus pass + // run first, so only the interaction below can clear the notification. + waitForRuntimeSurface(terminalPanel.surface) + drainMainQueue() + GhosttySurfaceScrollView.resetFlashCounts() store.addNotification( tabId: workspace.id, @@ -3384,9 +3388,13 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } - GhosttySurfaceScrollView.resetFlashCounts() AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) + // Let the runtime surface come up and the workspace's startup focus pass + // run first, so only the interaction below can clear the notification. + waitForRuntimeSurface(terminalPanel.surface) + drainMainQueue() + GhosttySurfaceScrollView.resetFlashCounts() store.addNotification( tabId: workspace.id, @@ -3397,12 +3405,13 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { ) XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - let event = makeKeyEvent(characters: "", keyCode: 122, window: window) + let event = makeKeyEvent(characters: "a", keyCode: 0, window: window) surfaceView.keyDown(with: event) drainMainQueue() XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + // Typing into the pane is the acknowledgement; the pane doesn't flash. + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 0) } func testKeyDownRecoversReleasedSurfaceWhileHostedViewIsDetached() throws { diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 1e3f89a5b772..4f542e87e1a4 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -740,38 +740,47 @@ final class TerminalDefaultFileOpenRequestTests: XCTestCase { final class FocusFlashPatternTests: XCTestCase { - func testFocusFlashPatternMatchesTerminalDoublePulseShape() { - XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) - XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) - XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) - XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + func testDefaultPatternIsOneShortPulse() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "FocusFlashPatternTests.\(UUID().uuidString)")) + XCTAssertTrue(NotificationPaneFlashSettings.usesDoubleBlink(defaults: defaults)) + + let pulse = FocusFlashPattern.pulse + XCTAssertEqual(pulse.values, [0, 1, 0]) + XCTAssertEqual(pulse.keyTimes, [0, 0.3, 1]) + XCTAssertEqual(pulse.duration, 0.6, accuracy: 0.0001) + XCTAssertEqual(pulse.curves, [.easeOut, .easeIn]) XCTAssertEqual(FocusFlashPattern.ringInset, Double(PanelOverlayRingMetrics.inset), accuracy: 0.0001) XCTAssertEqual(FocusFlashPattern.ringCornerRadius, Double(PanelOverlayRingMetrics.cornerRadius), accuracy: 0.0001) } - func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { - let segments = FocusFlashPattern.segments - XCTAssertEqual(segments.count, 4) + func testDoubleBlinkSettingSelectsTheOlderPattern() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "FocusFlashPatternTests.\(UUID().uuidString)")) + defaults.set(true, forKey: NotificationPaneFlashSettings.doubleBlinkKey) + XCTAssertTrue(NotificationPaneFlashSettings.usesDoubleBlink(defaults: defaults)) + + let doubleBlink = FocusFlashPattern.doubleBlink + XCTAssertEqual(doubleBlink.values, [0, 1, 0, 1, 0]) + XCTAssertEqual(doubleBlink.keyTimes, [0, 0.25, 0.5, 0.75, 1]) + XCTAssertEqual(doubleBlink.duration, 0.9, accuracy: 0.0001) + XCTAssertEqual(doubleBlink.segments.count, 4) + } + + func testPulseSegmentsCoverThePulse() { + let pulse = FocusFlashPattern.pulse + let segments = pulse.segments + XCTAssertEqual(segments.count, 2) XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001) - XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[0].duration, 0.18, accuracy: 0.0001) XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001) XCTAssertEqual(segments[0].curve, .easeOut) - XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].delay, 0.18, accuracy: 0.0001) + XCTAssertEqual(segments[1].duration, 0.42, accuracy: 0.0001) XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) XCTAssertEqual(segments[1].curve, .easeIn) - - XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) - XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001) - XCTAssertEqual(segments[2].curve, .easeOut) - - XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) - XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) - XCTAssertEqual(segments[3].curve, .easeIn) + XCTAssertEqual(pulse.opacity(at: 0.18), 1, accuracy: 0.0001) + XCTAssertEqual(pulse.opacity(at: 0.6), 0, accuracy: 0.0001) } } diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index b2915ee5e1fd..ac0a76ed1e8d 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -86,13 +86,16 @@ Notification behavior from Settings > Notifications. | `notifications.showInMenuBar` | boolean | `true` | Show the menu bar extra. | | `notifications.unreadPaneRing` | boolean | `true` | Highlight panes with unread notifications. | | `notifications.paneFlash` | boolean | `true` | Flash the focused pane when requested. | +| `notifications.paneFlashDoubleBlink` | boolean | `true` | Use the default double blink; set false for the configurable single 0.6-second pulse. | +| `notifications.paneFlashOnTyping` | boolean | `true` | Flash the pane when terminal typing dismisses its notification. Set false for a calmer typing interaction. | | `notifications.sound` | `"default"` or `"Basso"` or `"Blow"` or `"Bottle"` or `"Frog"` or `"Funk"` or `"Glass"` or `"Hero"` or `"Morse"` or `"Ping"` or `"Pop"` or `"Purr"` or `"Sosumi"` or `"Submarine"` or `"Tink"` or `"custom_file"` or `"none"` | `"default"` | Notification sound preset. | | `notifications.soundWhenFocused` | boolean | `false` | Play the notification sound even when the pane that notified is already focused. Off by default, so a focused pane shows only its ring and flash. | | `notifications.customSoundFilePath` | string | `""` | Local path to the custom notification sound file. | | `notifications.command` | string | `""` | Optional shell command to run alongside notification delivery. | | `notifications.hooksMode` | `"append"` or `"replace"` | `"append"` | Controls whether project-local notification hooks append to inherited hooks or replace them. | | `notifications.hooks` | array | `[]` | Composable shell hooks that receive notification policy JSON on stdin and return updated policy JSON on stdout. | -| `notifications.paneFlashColor` | string or null | `null` | Override the pane flash and unread ring color. Null keeps the built-in blue. | +| `notifications.paneFlashColor` | string or null | `null` | Override the pane flash and unread ring color. Null keeps the cmux blue default. | +| `notifications.paneFlashThemeColor` | boolean | `false` | Use the terminal theme foreground for flashes when paneFlashColor is unset; unread rings remain cmux blue. | | `notifications.suppressOnlyFocusedSurface` | boolean | `false` | When enabled, a notification banner is auto-withdrawn only when its surface is the exact focused surface. A banner delivered for a non-focused surface in the currently visible workspace stays up until you focus that surface (or click/dismiss it), instead of being retracted when the workspace becomes visible. Off preserves the legacy workspace-visibility withdraw. | | `notifications.suppressWhenAppFocused` | boolean | `false` | When enabled, cmux skips the desktop banner for every notification while cmux is the active app, not only for the focused pane. Notifications still appear in the sidebar, the sound and custom command still run, and phone forwarding is unchanged. Off keeps showing banners for other workspaces and panes while cmux is focused. | | `notifications.agentPermissionPrompt` | boolean | `true` | Notify when an agent (e.g. Claude Code) is blocked waiting for your permission to run a tool. On by default, since this is the alert you must act on to unblock the agent. | diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index c92a986eac12..fdb86f0529fa 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1153,12 +1153,30 @@ "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." + }, + "paneFlashOnTyping": { + "x-cmux-scopes": ["global"], + "type": "boolean", + "default": true, + "description": "Flash the pane when terminal typing dismisses its notification." + }, + "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"],