diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 21b888cd17b4..771add8bac67 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1126,6 +1126,13 @@ enum CmuxEmbeddedConfigSchema { "descriptionKey": "schemaDescriptions.notifications.suppressOnlyFocusedSurface", "description": "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." }, + "suppressWhenAppFocused": { + "x-cmux-scopes": ["global"], + "type": "boolean", + "default": false, + "descriptionKey": "schemaDescriptions.notifications.suppressWhenAppFocused", + "description": "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." + }, "agentPermissionPrompt": { "x-cmux-scopes": ["global"], "type": "boolean", diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift index 4941bf059565..4f775a196810 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/NotificationsCatalogSection.swift @@ -81,6 +81,18 @@ public struct NotificationsCatalogSection: SettingCatalogSection { userDefaultsKey: "notificationsSuppressOnlyFocusedSurface" ) + /// When enabled, cmux skips the desktop banner for every notification while + /// cmux is the active app, not only for the focused surface. The + /// notification is still recorded, the sound and custom command still run, + /// and phone forwarding keeps the focused-surface gate. Off keeps + /// delivering banners for other workspaces and panes while cmux is + /// focused. See issue #3126. + public let suppressWhenAppFocused = DefaultsKey( + id: "notifications.suppressWhenAppFocused", + defaultValue: false, + userDefaultsKey: "notificationsSuppressWhenAppFocused" + ) + /// Notify when an agent (e.g. Claude Code) is blocked waiting for the user's /// permission to run a tool. On by default: this is the one alert the user /// must act on to unblock the agent. diff --git a/Sources/CmuxSettingsFileStore+SupportedPaths.swift b/Sources/CmuxSettingsFileStore+SupportedPaths.swift index 601d966eaff5..571333ea3ea0 100644 --- a/Sources/CmuxSettingsFileStore+SupportedPaths.swift +++ b/Sources/CmuxSettingsFileStore+SupportedPaths.swift @@ -79,6 +79,7 @@ extension CmuxSettingsFileStore { "notifications.hooks", "notifications.hooksMode", "notifications.suppressOnlyFocusedSurface", + "notifications.suppressWhenAppFocused", "notifications.agentPermissionPrompt", "notifications.agentTurnComplete", "notifications.agentIdleReminder", diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 132e8d774ec0..5aa3c796ec1d 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -233,6 +233,10 @@ enum NotificationSettingsFileMapping { jsonKey: "suppressOnlyFocusedSurface", defaultsKey: notifications.suppressOnlyFocusedSurface.userDefaultsKey ), + .init( + jsonKey: "suppressWhenAppFocused", + defaultsKey: notifications.suppressWhenAppFocused.userDefaultsKey + ), .init( jsonKey: "agentPermissionPrompt", defaultsKey: notifications.agentPermissionPrompt.userDefaultsKey diff --git a/Sources/Feed/FeedCoordinator+NotificationDelivery.swift b/Sources/Feed/FeedCoordinator+NotificationDelivery.swift index 6d19b7affb9b..76f924242e8b 100644 --- a/Sources/Feed/FeedCoordinator+NotificationDelivery.swift +++ b/Sources/Feed/FeedCoordinator+NotificationDelivery.swift @@ -47,6 +47,7 @@ extension FeedCoordinator { } let ownerID = target.ownerID let surfaceID = target.surfaceID + let suppressWhenAppFocused = TerminalNotificationStore.isSuppressWhenAppFocusedEnabled() if let dock = appDelegate.existingWindowDock(forWindowId: ownerID) { let context = appDelegate.mainWindowContexts.values.first { @@ -60,7 +61,8 @@ extension FeedCoordinator { isFocusedSurface: isFocusedSurface, isMuted: false, soundWhenFocused: soundWhenFocused, - effects: effects + effects: effects, + suppressWhenAppFocused: suppressWhenAppFocused ) } @@ -80,7 +82,8 @@ extension FeedCoordinator { isFocusedSurface: isFocusedSurface, isMuted: isMuted, soundWhenFocused: soundWhenFocused, - effects: effects + effects: effects, + suppressWhenAppFocused: suppressWhenAppFocused ) } diff --git a/Sources/TerminalNotificationArrivalDisposition.swift b/Sources/TerminalNotificationArrivalDisposition.swift index 5e1a54030599..3446268d5dfb 100644 --- a/Sources/TerminalNotificationArrivalDisposition.swift +++ b/Sources/TerminalNotificationArrivalDisposition.swift @@ -3,8 +3,4 @@ enum TerminalNotificationArrivalDisposition: Equatable, Sendable { case externalDelivery case focusedInline case muted - - var suppressesPhoneForward: Bool { - self != .externalDelivery - } } diff --git a/Sources/TerminalNotificationDeliveryDecision.swift b/Sources/TerminalNotificationDeliveryDecision.swift index deebb216c71c..9b0bdd2da9eb 100644 --- a/Sources/TerminalNotificationDeliveryDecision.swift +++ b/Sources/TerminalNotificationDeliveryDecision.swift @@ -9,7 +9,8 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable { isFocusedSurface: Bool, isMuted: Bool, soundWhenFocused: Bool, - effects: TerminalNotificationPolicyEffects + effects: TerminalNotificationPolicyEffects, + suppressWhenAppFocused: Bool = false ) -> Self { if isMuted { // A workspace mute drops every effect before history, badges, @@ -18,6 +19,14 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable { } guard isAppFocused, isActiveTab, isFocusedSurface else { + if isAppFocused, suppressWhenAppFocused { + // `notifications.suppressWhenAppFocused`: cmux is the active + // app, so skip the banner but keep the sound, pane flash, + // and custom command for this arrival. + var appFocusedEffects = effects + appFocusedEffects.desktop = false + return Self(disposition: .focusedInline, effects: appFocusedEffects) + } return Self(disposition: .externalDelivery, effects: effects) } diff --git a/Sources/TerminalNotificationStore+FocusState.swift b/Sources/TerminalNotificationStore+FocusState.swift index 1de88a8ee4d6..943cc3fb1244 100644 --- a/Sources/TerminalNotificationStore+FocusState.swift +++ b/Sources/TerminalNotificationStore+FocusState.swift @@ -1,6 +1,33 @@ +import CmuxSettings import Foundation +extension TerminalNotificationStore.NotificationFocusState { + /// The notification targets the exact surface the user is looking at. + var isFocusedSurfaceArrival: Bool { + isAppFocused && isActiveTab && isFocusedSurface + } +} + extension TerminalNotificationStore { + private static let notificationsSettings = NotificationsCatalogSection() + + /// Opt-in `notifications.suppressWhenAppFocused` (issue #3126): when on, + /// any notification that arrives while cmux is the active app skips the + /// desktop banner, not only one for the focused surface. + static func isSuppressWhenAppFocusedEnabled(defaults: UserDefaults = .standard) -> Bool { + notificationsSettings.suppressWhenAppFocused.value(in: defaults) + } + + static func shouldSuppressExternalDelivery( + _ focusState: NotificationFocusState, + suppressWhenAppFocused: Bool + ) -> Bool { + if suppressWhenAppFocused { + return focusState.isAppFocused + } + return focusState.isFocusedSurfaceArrival + } + func notificationFocusState( tabId: UUID, surfaceId: UUID? diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index dfa01c1d1025..fef964bbbc56 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1510,12 +1510,17 @@ final class TerminalNotificationStore: ObservableObject { restoreCooldownReservation(cooldownReservation) return false } - let shouldSuppressExternalDelivery = shouldSuppressExternalDelivery( - tabId: request.tabId, - surfaceId: request.surfaceId + let focusState = notificationFocusState(tabId: request.tabId, surfaceId: request.surfaceId) + let shouldSuppressExternalDelivery = Self.shouldSuppressExternalDelivery( + focusState, + suppressWhenAppFocused: Self.isSuppressWhenAppFocusedEnabled() ) + let isFocusedSurfaceArrival = focusState.isFocusedSurfaceArrival + // Only the exact focused pane holds the workspace in place; + // `suppressWhenAppFocused` withholds the banner without changing + // sidebar ordering, matching Feed's delivery decision. let effects = effects.keepingFocusedWorkspaceInPlace( - isFocusedPane: shouldSuppressExternalDelivery + isFocusedPane: isFocusedSurfaceArrival ) let notification = TerminalNotification( id: notificationID, @@ -1539,6 +1544,7 @@ final class TerminalNotificationStore: ObservableObject { if effects.record { recordNotification( notification, + isFocusedSurfaceArrival: isFocusedSurfaceArrival, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, effects: effects, now: now, @@ -1562,6 +1568,7 @@ final class TerminalNotificationStore: ObservableObject { } deliverNotificationSideEffects( notification, + isFocusedSurfaceArrival: isFocusedSurfaceArrival, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, effects: effects ) @@ -1569,6 +1576,7 @@ final class TerminalNotificationStore: ObservableObject { } private func recordNotification( _ notification: TerminalNotification, + isFocusedSurfaceArrival: Bool, shouldSuppressExternalDelivery: Bool, effects: TerminalNotificationPolicyEffects, now: Date, @@ -1594,7 +1602,7 @@ final class TerminalNotificationStore: ObservableObject { focusedReadIndicatorByTabId.removeValue(forKey: notification.tabId) } - if shouldSuppressExternalDelivery, effects.markUnread { + if isFocusedSurfaceArrival, effects.markUnread { setFocusedReadIndicator(forTabId: notification.tabId, surfaceId: notification.surfaceId) } @@ -1641,20 +1649,15 @@ final class TerminalNotificationStore: ObservableObject { } deliverNotificationSideEffects( notification, + isFocusedSurfaceArrival: isFocusedSurfaceArrival, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, effects: effects ) } - private func shouldSuppressExternalDelivery(tabId: UUID, surfaceId: UUID?) -> Bool { - let focusState = notificationFocusState(tabId: tabId, surfaceId: surfaceId) - return focusState.isAppFocused - && focusState.isActiveTab - && focusState.isFocusedSurface - } - private func deliverNotificationSideEffects( _ notification: TerminalNotification, + isFocusedSurfaceArrival: Bool, shouldSuppressExternalDelivery: Bool, effects: TerminalNotificationPolicyEffects ) { @@ -1665,12 +1668,17 @@ final class TerminalNotificationStore: ObservableObject { #endif if effects.desktop || effects.sound || effects.command { if shouldSuppressExternalDelivery { + // Only the pane the user is looking at goes quiet; + // `suppressWhenAppFocused` withholds just the banner for + // other panes, matching Feed's delivery decision. suppressedNotificationFeedbackHandler( self, notification, - effects.keepingFocusedPaneQuiet( - soundWhenFocused: NotificationSoundSettings.soundWhenFocused() - ) + isFocusedSurfaceArrival + ? effects.keepingFocusedPaneQuiet( + soundWhenFocused: NotificationSoundSettings.soundWhenFocused() + ) + : effects ) } else { notificationDeliveryHandler(self, notification, effects) @@ -1681,7 +1689,10 @@ final class TerminalNotificationStore: ObservableObject { tabId: notification.tabId, surfaceId: notification.surfaceId ) - let shouldAttemptPhone = !shouldSuppressExternalDelivery + // `suppressWhenAppFocused` only withholds the desktop banner: the Mac + // may be frontmost with nobody at it, so phone forwarding keeps the + // exact focused-surface gate. + let shouldAttemptPhone = !isFocusedSurfaceArrival && Self.shouldAttemptPhoneForward( effects: effects, phoneForwardingEnabled: PhonePushClient.shared diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bb4779b80fe3..b8ad74393f9a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2421,6 +2421,7 @@ D7769000000000000000000F /* NotificationsPopoverMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000010 /* NotificationsPopoverMetrics.swift */; }; D77690000000000000000007 /* NotificationsPopoverResizeHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000008 /* NotificationsPopoverResizeHandle.swift */; }; D77690000000000000000009 /* NotificationsPopoverResizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000A /* NotificationsPopoverResizeView.swift */; }; + 2C9949001245BCD676E5ACA6 /* NotificationSuppressWhenAppFocusedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */; }; CF5D844BBCB1482280ABA319 /* NSImageView+SidebarWorkspaceAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21111C055F1D46288E928B8C /* NSImageView+SidebarWorkspaceAccessory.swift */; }; F12748000000000000000005 /* NSTextView+FilePreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12748000000000000000006 /* NSTextView+FilePreviewLayout.swift */; }; C01E00080000000000000001 /* NSTextView+FilePreviewTabWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01E00080000000000000002 /* NSTextView+FilePreviewTabWidth.swift */; }; @@ -6466,6 +6467,7 @@ D77690000000000000000010 /* NotificationsPopoverMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverMetrics.swift; sourceTree = ""; }; D77690000000000000000008 /* NotificationsPopoverResizeHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverResizeHandle.swift; sourceTree = ""; }; D7769000000000000000000A /* NotificationsPopoverResizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverResizeView.swift; sourceTree = ""; }; + 5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationSuppressWhenAppFocusedTests.swift"; sourceTree = ""; }; 21111C055F1D46288E928B8C /* NSImageView+SidebarWorkspaceAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImageView+SidebarWorkspaceAccessory.swift"; sourceTree = ""; }; F12748000000000000000006 /* NSTextView+FilePreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/NSTextView+FilePreviewLayout.swift"; sourceTree = ""; }; C01E00080000000000000002 /* NSTextView+FilePreviewTabWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/NSTextView+FilePreviewTabWidth.swift"; sourceTree = ""; }; @@ -12340,6 +12342,7 @@ 23E35F95320CBED308AD98CF /* CloudTreeTogglePerformanceTests.swift */, 0C37EA14DE74095FB1BB23E1 /* CloudTreeDisclosureIntentTests.swift */, 6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */, + 5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */, 4772A4FA42EB29D54779B2F4 /* GhosttyConfigDiagnosticsNoticePlacementTests.swift */, ); path = cmuxTests; @@ -16584,6 +16587,7 @@ C79010000000000000000003 /* NotificationScrollRestoreRowSpaceTests.swift in Sources */, C79010000000000000000005 /* NotificationScrollRestoreTestSupport.swift in Sources */, 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */, + 2C9949001245BCD676E5ACA6 /* NotificationSuppressWhenAppFocusedTests.swift in Sources */, 645645000000000000000002 /* NumberedShortcutSwapTests.swift in Sources */, 0A1107110000000000000001 /* OllamaAgentDetectionTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, diff --git a/cmuxTests/AgentNotificationMoveRaceTests.swift b/cmuxTests/AgentNotificationMoveRaceTests.swift index caaef148306d..23ae60c6fd9c 100644 --- a/cmuxTests/AgentNotificationMoveRaceTests.swift +++ b/cmuxTests/AgentNotificationMoveRaceTests.swift @@ -113,6 +113,19 @@ struct AgentNotificationRegressionTests { } } + /// Waits until a matching notification is recorded. Assertions after + /// this call still decide the outcome; the deadline only bounds the + /// failure path. + func waitForNotifications( + in store: TerminalNotificationStore, + matching predicate: (TerminalNotification) -> Bool + ) async { + let deadline = ContinuousClock.now + .seconds(15) + while !store.notifications.contains(where: predicate), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } + private func firstPolicyCompletion( from stream: AsyncStream, within timeout: Duration @@ -579,7 +592,10 @@ struct AgentNotificationRegressionTests { fixture.store.clearNotifications(forTabId: fixture.destination.id) #expect(await waitForFile(at: completionURL)) - for _ in 0..<100 { await Task.yield() } + // The hook touches the marker before it exits and its output is + // applied, so wait for the delivery itself rather than a fixed + // number of yields. + await waitForNotifications(in: fixture.store) { $0.title == "Relay" } let recorded = fixture.store.notifications.filter { $0.title == "Relay" } #expect(recorded.map(\.tabId) == [fixture.source.id]) #expect(!recorded.contains { $0.tabId == fixture.destination.id }) @@ -623,6 +639,10 @@ struct AgentNotificationRegressionTests { fixture.store.clearNotifications(forTabId: fixture.source.id, surfaceId: fixture.panelId) #expect(await waitForFile(at: completionURL)) + await waitForNotifications(in: fixture.store) { $0.title == "Relay live" } + // Both hooks touch the same marker, so the stale "Relay" delivery may + // still be applying; give it the settling window the negative + // assertion below relied on before. for _ in 0..<100 { await Task.yield() } let recorded = fixture.store.notifications.filter { $0.title.hasPrefix("Relay") } #expect(recorded.map(\.tabId) == [fixture.destination.id]) diff --git a/cmuxTests/NotificationSuppressWhenAppFocusedTests.swift b/cmuxTests/NotificationSuppressWhenAppFocusedTests.swift new file mode 100644 index 000000000000..638bd5ea4ab3 --- /dev/null +++ b/cmuxTests/NotificationSuppressWhenAppFocusedTests.swift @@ -0,0 +1,150 @@ +import AppKit +import CmuxSettings +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression tests for https://github.com/manaflow-ai/cmux/issues/3126: +/// `notifications.suppressWhenAppFocused` skips the desktop banner for every +/// notification while cmux is the active app, and stays off by default. +extension AgentNotificationRegressionTests { + private static let notificationsSettings = NotificationsCatalogSection() + + private func focusState(app: Bool, tab: Bool, surface: Bool) -> TerminalNotificationStore.NotificationFocusState { + TerminalNotificationStore.NotificationFocusState( + isAppFocused: app, + isActiveTab: tab, + isFocusedSurface: surface, + workspace: nil, + cmuxConfigStore: nil + ) + } + + @Test + func testSuppressWhenAppFocusedDecisionMatrix() { + let backgroundPane = focusState(app: true, tab: false, surface: false) + let focusedPane = focusState(app: true, tab: true, surface: true) + let appInBackground = focusState(app: false, tab: true, surface: true) + + // Default (off): only the exact focused pane is suppressed. + #expect(!TerminalNotificationStore.shouldSuppressExternalDelivery(backgroundPane, suppressWhenAppFocused: false)) + #expect(TerminalNotificationStore.shouldSuppressExternalDelivery(focusedPane, suppressWhenAppFocused: false)) + #expect(!TerminalNotificationStore.shouldSuppressExternalDelivery(appInBackground, suppressWhenAppFocused: false)) + + // On: anything while the app is focused is suppressed; background app still delivers. + #expect(TerminalNotificationStore.shouldSuppressExternalDelivery(backgroundPane, suppressWhenAppFocused: true)) + #expect(TerminalNotificationStore.shouldSuppressExternalDelivery(focusedPane, suppressWhenAppFocused: true)) + #expect(!TerminalNotificationStore.shouldSuppressExternalDelivery(appInBackground, suppressWhenAppFocused: true)) + } + + @Test + func testFeedDeliveryDecisionHonorsSuppressWhenAppFocused() { + var effects = TerminalNotificationPolicyEffects() + effects.desktop = true + effects.sound = true + + let legacy = TerminalNotificationDeliveryDecision.resolve( + isAppFocused: true, isActiveTab: false, isFocusedSurface: false, + isMuted: false, soundWhenFocused: false, effects: effects + ) + #expect(legacy.disposition == .externalDelivery) + #expect(legacy.effects.desktop) + + let suppressed = TerminalNotificationDeliveryDecision.resolve( + isAppFocused: true, isActiveTab: false, isFocusedSurface: false, + isMuted: false, soundWhenFocused: false, effects: effects, suppressWhenAppFocused: true + ) + #expect(suppressed.disposition == .focusedInline) + #expect(!suppressed.effects.desktop) + #expect(suppressed.effects.sound) + + let appInBackground = TerminalNotificationDeliveryDecision.resolve( + isAppFocused: false, isActiveTab: false, isFocusedSurface: false, + isMuted: false, soundWhenFocused: false, effects: effects, suppressWhenAppFocused: true + ) + #expect(appInBackground.disposition == .externalDelivery) + #expect(appInBackground.effects.desktop) + } + + @Test + func testSuppressWhenAppFocusedDefaultsOff() throws { + let suiteName = "cmux-suppress-when-app-focused-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(!TerminalNotificationStore.isSuppressWhenAppFocusedEnabled(defaults: defaults)) + Self.notificationsSettings.suppressWhenAppFocused.set(true, in: defaults) + #expect(TerminalNotificationStore.isSuppressWhenAppFocusedEnabled(defaults: defaults)) + } + + @Test(arguments: [false, true]) + func testBackgroundWorkspaceBannerFollowsSuppressWhenAppFocused(enabled: Bool) throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let defaults = UserDefaults.standard + let key = Self.notificationsSettings.suppressWhenAppFocused + let originalValue = defaults.object(forKey: key.userDefaultsKey) + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + var deliveredIDs: [UUID] = [] + var suppressedIDs: [UUID] = [] + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredIDs.append(notification.id) + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, notification in + suppressedIDs.append(notification.id) + } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + key.set(enabled, in: defaults) + + let backgroundWorkspace = manager.addWorkspace(select: false) + let selectedWorkspace = manager.addWorkspace(select: true) + + defer { + for workspace in [backgroundWorkspace, selectedWorkspace] + where manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalValue { + defaults.set(originalValue, forKey: key.userDefaultsKey) + } else { + defaults.removeObject(forKey: key.userDefaultsKey) + } + } + + let panelId = try #require(backgroundWorkspace.focusedPanelId) + store.addNotification( + tabId: backgroundWorkspace.id, + surfaceId: panelId, + title: "Background", + subtitle: "", + body: "" + ) + + let createdID = try #require(store.notifications.first?.id) + #expect(store.hasUnreadNotification(forTabId: backgroundWorkspace.id, surfaceId: panelId)) + if enabled { + #expect(deliveredIDs.isEmpty) + #expect(suppressedIDs == [createdID]) + #expect(store.focusedReadIndicatorSurfaceId(forTabId: backgroundWorkspace.id) == nil) + } else { + #expect(deliveredIDs == [createdID]) + #expect(suppressedIDs.isEmpty) + } + } +} diff --git a/docs/notifications.md b/docs/notifications.md index 96e34e99e87f..77b6fca50852 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -115,6 +115,20 @@ By default cmux withdraws a delivered banner when its workspace becomes visible/ } ``` +## Suppress banners while cmux is focused + +By default cmux skips the desktop banner only when the notification is for the pane you are looking at. A notification for another workspace or pane still shows a banner even while cmux is the active app. Set the opt-in flag below to `true` to skip the desktop banner for every notification while cmux is the active app. Notifications that arrive after you switch to another app show banners again. Notifications still land in the sidebar with their unread state, the notification sound and custom command still run, and phone forwarding keeps its usual focused-pane rule, since cmux can be frontmost while you are away from the Mac. + +```jsonc +{ + "notifications": { + // Default: false (only the focused pane skips its banner). + // Set to true to skip banners for every notification while cmux is focused. + "suppressWhenAppFocused": true + } +} +``` + ## Notification Hooks `cmux.json` can define composable hooks that receive every notification policy as JSON on stdin and return updated JSON on stdout. Hooks are off by default; cmux only runs them when `notifications.hooks` contains at least one enabled hook. Hooks can filter native banners, sidebar history, sounds, custom commands, workspace reordering, and pane flashes. diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index b6b9f5159dcf..aeb1d75187b3 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -90,6 +90,7 @@ Notification behavior from Settings > Notifications. | `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.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. | | `notifications.agentTurnComplete` | `"whenIdle"` or `"always"` or `"never"` | `"whenIdle"` | When to notify that an agent finished a turn. whenIdle (default) suppresses the notification while the agent still has a running background task or a pending scheduled wakeup, so you are pinged once work truly drains. always notifies on every turn end; never disables it. | | `notifications.agentIdleReminder` | boolean | `true` | Notify when an agent has been idle waiting for your input (about 60s after a turn ends). Suppressed while background work from the last turn is still pending, so a running build or watcher does not trigger a false waiting alert. | diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 5c21df12b39a..479995368078 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1116,6 +1116,13 @@ "descriptionKey": "schemaDescriptions.notifications.suppressOnlyFocusedSurface", "description": "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." }, + "suppressWhenAppFocused": { + "x-cmux-scopes": ["global"], + "type": "boolean", + "default": false, + "descriptionKey": "schemaDescriptions.notifications.suppressWhenAppFocused", + "description": "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." + }, "agentPermissionPrompt": { "x-cmux-scopes": ["global"], "type": "boolean", diff --git a/web/messages/ar.json b/web/messages/ar.json index 8f0882c7990f..5b0cf4f5d330 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "شغّل صوت الإشعار حتى عندما تكون اللوحة التي أرسلت الإشعار في موضع التركيز بالفعل. معطّل افتراضيًا، لذا تعرض اللوحة المُركَّز عليها الحلقة والوميض فقط.", "suppressOnlyFocusedSurface": "عند التفعيل، تُسحب لافتة الإشعار تلقائيًا فقط عندما يكون سطحها هو السطح المُركَّز عليه تحديدًا. واللافتة المُسلَّمة لسطح غير مُركَّز عليه ضمن مساحة العمل المرئية حاليًا تبقى ظاهرة حتى تركّز على ذلك السطح (أو تنقر عليه/تغلقه)، بدلًا من سحبها عند ظهور مساحة العمل. الإيقاف يحافظ على سلوك السحب التقليدي المعتمد على ظهور مساحة العمل.", + "suppressWhenAppFocused": "عند التفعيل، يتخطى cmux لافتة سطح المكتب لكل إشعار أثناء كون cmux التطبيق النشط، وليس فقط للجزء المُركَّز عليه. تظل الإشعارات ظاهرة في الشريط الجانبي، ويستمر تشغيل الصوت والأمر المخصص، ولا تتغير إعادة التوجيه إلى الهاتف. عند الإيقاف تستمر اللافتات في الظهور لمساحات العمل والأجزاء الأخرى أثناء التركيز على cmux.", "agentIdleReminder": "أرسل إشعارًا عندما يكون الوكيل خاملاً وينتظر إدخالك (بعد نحو 60 ثانية من انتهاء الدور). يُكتم الإشعار ما دام عمل الخلفية من الدور السابق قيد التنفيذ، حتى لا يؤدي تشغيل بناء أو مراقب إلى تنبيه انتظار خاطئ.", "soundOverrides": "تجاوزات متفرقة لصوت الإشعار لكل وكيل ولكل نوع تنبيه. تستخدم الخلايا المفقودة notifications.sound.", "soundOverride": "تجاوز لصوت الإشعار. يستخدم default قيمة notifications.sound، ويعطّل none التشغيل، وتختار القيم الأخرى صوتًا مدمجًا أو مخصصًا." diff --git a/web/messages/bs.json b/web/messages/bs.json index 16df95f99175..5a99a9f60e46 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Reprodukuj zvuk obavještenja čak i kada je panel koji je poslao obavještenje već u fokusu. Isključeno po zadanom, pa fokusirani panel prikazuje samo prsten i bljesak.", "suppressOnlyFocusedSurface": "Kada je uključeno, baner notifikacije se automatski povlači samo kada je njegova površina tačno fokusirana površina. Baner isporučen za nefokusiranu površinu u trenutno vidljivom workspaceu ostaje prikazan dok ne fokusirate tu površinu (ili je kliknete/odbacite), umjesto da bude povučen kada workspace postane vidljiv. Isključeno zadržava staro ponašanje povlačenja na osnovu vidljivosti workspacea.", + "suppressWhenAppFocused": "Kada je uključeno, cmux preskače baner na radnoj površini za svaku notifikaciju dok je cmux aktivna aplikacija, a ne samo za fokusirani panel. Notifikacije se i dalje prikazuju u bočnoj traci, zvuk i prilagođena komanda se i dalje pokreću, a prosljeđivanje na telefon ostaje isto. Isključeno i dalje prikazuje banere za druge workspaceove i panele dok je cmux u fokusu.", "agentIdleReminder": "Pošalji obavijest kada agent miruje i čeka vaš unos (oko 60 sekundi nakon završetka turna). Utišano je dok je pozadinski rad prethodnog turna još na čekanju, pa pokrenuta izgradnja ili nadzor ne izazivaju pogrešnu obavijest o čekanju.", "soundOverrides": "Rijetka nadjačavanja zvuka obavijesti po agentu i vrsti upozorenja. Ćelije koje nedostaju koriste notifications.sound.", "soundOverride": "Nadjačavanje zvuka obavijesti. default koristi notifications.sound, none onemogućava reprodukciju, a ostale vrijednosti biraju ugrađeni ili prilagođeni zvuk." diff --git a/web/messages/da.json b/web/messages/da.json index ef2f8a13622d..34aadc7375bd 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Afspil notifikationslyden, selv når ruden, der sendte notifikationen, allerede har fokus. Slået fra som standard, så en rude med fokus kun viser sin ring og sit blink.", "suppressOnlyFocusedSurface": "Når dette er slået til, trækkes et notifikationsbanner kun automatisk tilbage, når dets surface er præcis den fokuserede surface. Et banner leveret til en ikke-fokuseret surface i det aktuelt synlige workspace bliver vist, indtil du fokuserer på den surface (eller klikker/afviser det), i stedet for at blive trukket tilbage, når workspacet bliver synligt. Fra bevarer den ældre tilbagetrækning baseret på workspace-synlighed.", + "suppressWhenAppFocused": "Når dette er slået til, springer cmux skrivebordsbanneret over for alle notifikationer, mens cmux er den aktive app, ikke kun for det fokuserede panel. Notifikationer vises stadig i sidebjælken, lyden og den brugerdefinerede kommando kører stadig, og videresendelse til telefonen er uændret. Fra bliver ved med at vise bannere for andre workspaces og paneler, mens cmux er i fokus.", "agentIdleReminder": "Send en notifikation, når en agent har været inaktiv og venter på dit input (ca. 60 sekunder efter en tur slutter). Undertrykkes, mens baggrundsarbejde fra den seneste tur stadig kører, så en igangværende build eller overvågning ikke udløser en falsk vente-notifikation.", "soundOverrides": "Sparsomme tilsidesættelser af notifikationslyd pr. agent og alarmtype. Manglende felter bruger notifications.sound.", "soundOverride": "En tilsidesættelse af notifikationslyden. default bruger notifications.sound, none deaktiverer afspilning, og de øvrige værdier vælger en indbygget eller brugerdefineret lyd." diff --git a/web/messages/de.json b/web/messages/de.json index 6f324e45c6e3..e9e2eb068869 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Den Benachrichtigungston auch dann abspielen, wenn der benachrichtigende Bereich bereits fokussiert ist. Standardmäßig aus, sodass ein fokussierter Bereich nur Ring und Aufblinken zeigt.", "suppressOnlyFocusedSurface": "Wenn aktiviert, wird ein Benachrichtigungsbanner nur dann automatisch zurückgezogen, wenn seine Oberfläche genau die fokussierte Oberfläche ist. Ein Banner, das für eine nicht fokussierte Oberfläche im aktuell sichtbaren Workspace zugestellt wurde, bleibt sichtbar, bis Sie diese Oberfläche fokussieren (oder das Banner anklicken bzw. schließen), anstatt zurückgezogen zu werden, sobald der Workspace sichtbar wird. Deaktiviert bleibt das bisherige Zurückziehen anhand der Workspace-Sichtbarkeit erhalten.", + "suppressWhenAppFocused": "Wenn aktiviert, überspringt cmux das Desktop-Banner für jede Benachrichtigung, solange cmux die aktive App ist, nicht nur für den fokussierten Bereich. Benachrichtigungen erscheinen weiterhin in der Seitenleiste, Ton und benutzerdefinierter Befehl werden weiterhin ausgeführt, und die Weiterleitung ans Telefon bleibt unverändert. Deaktiviert werden Banner für andere Workspaces und Bereiche weiterhin angezeigt, während cmux fokussiert ist.", "agentIdleReminder": "Benachrichtige, wenn ein Agent untätig auf deine Eingabe wartet (etwa 60 Sekunden nach Ende eines Turns). Wird unterdrückt, solange Hintergrundarbeit des letzten Turns noch aussteht, damit ein laufender Build oder Watcher keinen falschen Wartehinweis auslöst.", "soundOverrides": "Sparsame Überschreibungen des Benachrichtigungstons pro Agent und Alarmtyp. Nicht gesetzte Felder verwenden notifications.sound.", "soundOverride": "Eine Überschreibung des Benachrichtigungstons. default verwendet notifications.sound, none deaktiviert die Wiedergabe; die übrigen Werte wählen einen integrierten oder benutzerdefinierten Ton." diff --git a/web/messages/en.json b/web/messages/en.json index 9208cae1d74c..759d710650c4 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -2657,6 +2657,7 @@ "paneFlashColor": "Override the pane flash and unread notification ring color. Leave unset to use the accent from app.accentColor.", "soundWhenFocused": "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.", "suppressOnlyFocusedSurface": "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.", + "suppressWhenAppFocused": "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.", "agentPermissionPrompt": "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.", "agentTurnComplete": "When to notify that an agent finished a turn. whenIdle (default) suppresses the notification while the agent still has a running background task or a pending scheduled wakeup, so you are pinged once work truly drains. always notifies on every turn end; never disables it.", "agentIdleReminder": "Notify when an agent has been idle waiting for your input (about 60s after a turn ends). Suppressed while background work from the last turn is still pending, so a running build or watcher does not trigger a false waiting alert.", diff --git a/web/messages/es.json b/web/messages/es.json index ebf0169469fb..ecaa1c10b66d 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Reproduce el sonido de notificación incluso cuando el panel que notificó ya tiene el foco. Desactivado de forma predeterminada, así que un panel con foco solo muestra su anillo y destello.", "suppressOnlyFocusedSurface": "Cuando está activado, un banner de notificación se retira automáticamente solo cuando su superficie es exactamente la superficie enfocada. Un banner entregado para una superficie no enfocada en el workspace actualmente visible permanece visible hasta que enfoques esa superficie (o hagas clic en él/lo descartes), en lugar de retirarse cuando el workspace se vuelve visible. Desactivado conserva el comportamiento heredado de retirada según la visibilidad del workspace.", + "suppressWhenAppFocused": "Cuando está activado, cmux omite el banner de escritorio para todas las notificaciones mientras cmux es la app activa, no solo para el panel enfocado. Las notificaciones siguen apareciendo en la barra lateral, el sonido y el comando personalizado se siguen ejecutando, y el reenvío al teléfono no cambia. Desactivado sigue mostrando banners de otros workspaces y paneles mientras cmux tiene el foco.", "agentIdleReminder": "Notifica cuando un agente ha estado inactivo esperando tu entrada (unos 60 segundos después de terminar un turno). Se suprime mientras siga pendiente el trabajo en segundo plano del turno anterior, para que una compilación o un monitor en ejecución no active una alerta de espera falsa.", "soundOverrides": "Anulaciones dispersas del sonido de notificación por agente y tipo de alerta. Las celdas ausentes usan notifications.sound.", "soundOverride": "Una anulación del sonido de notificación. default usa notifications.sound, none desactiva la reproducción y los demás valores seleccionan un sonido integrado o personalizado." diff --git a/web/messages/fr.json b/web/messages/fr.json index e44e76d72bc9..f5fcdfbb7c4f 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Jouer le son de notification même lorsque le panneau à l’origine de la notification a déjà le focus. Désactivé par défaut : un panneau qui a le focus affiche seulement son anneau et son clignotement.", "suppressOnlyFocusedSurface": "Lorsque cette option est activée, une bannière de notification n'est retirée automatiquement que lorsque sa surface est exactement la surface ayant le focus. Une bannière affichée pour une surface sans focus dans le workspace actuellement visible reste affichée jusqu'à ce que vous donniez le focus à cette surface (ou que vous cliquiez dessus / la fermiez), au lieu d'être retirée lorsque le workspace devient visible. Désactivée, cette option conserve l'ancien retrait basé sur la visibilité du workspace.", + "suppressWhenAppFocused": "Lorsque cette option est activée, cmux ignore la bannière du bureau pour chaque notification tant que cmux est l'app active, et pas seulement pour le panneau ayant le focus. Les notifications apparaissent toujours dans la barre latérale, le son et la commande personnalisée sont toujours exécutés, et le transfert vers le téléphone reste inchangé. Désactivée, les bannières restent affichées pour les autres workspaces et panneaux lorsque cmux a le focus.", "agentIdleReminder": "Notifier lorsqu’un agent est inactif et attend votre saisie (environ 60 secondes après la fin d’un tour). La notification est supprimée tant qu’un travail en arrière-plan du tour précédent est encore en attente, afin qu’un build ou un watcher en cours ne déclenche pas une fausse alerte d’attente.", "soundOverrides": "Remplacements ponctuels du son de notification par agent et par type d’alerte. Les cellules absentes utilisent notifications.sound.", "soundOverride": "Un remplacement du son de notification. default utilise notifications.sound, none désactive la lecture et les autres valeurs sélectionnent un son intégré ou personnalisé." diff --git a/web/messages/it.json b/web/messages/it.json index 0428f1ad1f63..e01f19365a7a 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Riproduci il suono di notifica anche quando il pannello che ha inviato la notifica è già a fuoco. Disattivato per impostazione predefinita, quindi un pannello a fuoco mostra solo l’anello e il lampeggio.", "suppressOnlyFocusedSurface": "Quando è attivo, un banner di notifica viene ritirato automaticamente solo quando la sua superficie è esattamente la superficie con il focus. Un banner mostrato per una superficie senza focus nel workspace attualmente visibile resta visibile finché non porti il focus su quella superficie (o ci fai clic/la chiudi), invece di essere ritirato quando il workspace diventa visibile. Disattivato mantiene il vecchio ritiro basato sulla visibilità del workspace.", + "suppressWhenAppFocused": "Quando è attivo, cmux salta il banner sul desktop per ogni notifica mentre cmux è l'app attiva, non solo per il pannello con il focus. Le notifiche compaiono comunque nella barra laterale, il suono e il comando personalizzato vengono comunque eseguiti e l'inoltro al telefono non cambia. Disattivato continua a mostrare i banner per altri workspace e pannelli mentre cmux ha il focus.", "agentIdleReminder": "Notifica quando un agente è inattivo in attesa del tuo input (circa 60 secondi dopo la fine di un turno). La notifica è soppressa finché resta del lavoro in background del turno precedente, così una build o un watcher in esecuzione non attivano un falso avviso di attesa.", "soundOverrides": "Override sparsi del suono di notifica per agente e tipo di avviso. Le celle mancanti usano notifications.sound.", "soundOverride": "Un override del suono di notifica. default usa notifications.sound, none disabilita la riproduzione e gli altri valori selezionano un suono integrato o personalizzato." diff --git a/web/messages/ja.json b/web/messages/ja.json index 2450f7d1c544..05e1e7b5017f 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -2580,6 +2580,7 @@ "paneFlashColor": "ペインのフラッシュと未読通知リングの色を上書きします。未設定の場合は app.accentColor のアクセントカラーを使用します。", "soundWhenFocused": "通知を送ったペインがすでにフォーカスされている場合でも通知音を鳴らします。既定ではオフで、フォーカス中のペインはリングとフラッシュのみを表示します。", "suppressOnlyFocusedSurface": "有効にすると、通知バナーはその対象がまさにフォーカス中のサーフェスである場合のみ自動的に取り下げられます。現在表示中のワークスペース内でフォーカスされていないサーフェス向けに配信されたバナーは、ワークスペースが表示されたときに取り消されるのではなく、そのサーフェスをフォーカスする(またはクリック/消去する)まで表示され続けます。オフの場合は従来のワークスペース表示時の取り下げ動作を維持します。", + "suppressWhenAppFocused": "有効にすると、cmux がアクティブなアプリである間は、フォーカス中のペインだけでなくすべての通知でデスクトップバナーを省略します。通知はサイドバーに引き続き表示され、サウンドとカスタムコマンドも実行され、スマートフォンへの転送は変わりません。オフの場合、cmux にフォーカスがあっても他のワークスペースやペインのバナーは表示されます。", "agentPermissionPrompt": "エージェント(Claude Code など)がツールの実行にあなたの許可を待って停止しているときに通知します。エージェントの停止を解除するには操作が必要なため、既定でオンです。", "agentTurnComplete": "エージェントがターンを終えたことを通知するタイミングです。whenIdle(既定)は、実行中のバックグラウンドタスクや保留中のスケジュール再開が残っている間は通知を抑制し、作業が完全に片付いてから通知します。always はターン終了ごとに通知し、never は通知しません。", "agentIdleReminder": "エージェントがあなたの入力を待って待機状態になったとき(ターン終了の約60秒後)に通知します。前のターンのバックグラウンド作業がまだ実行中の間は抑制されるため、ビルドやウォッチャーの実行中に誤った待機通知が出ることはありません。", diff --git a/web/messages/km.json b/web/messages/km.json index ff2fbac11f6b..9ecec0b41915 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "ចាក់សំឡេងជូនដំណឹង ទោះបីផ្ទាំងដែលបានជូនដំណឹងកំពុងស្ថិតក្នុងការផ្តោតរួចហើយក៏ដោយ។ បិទតាមលំនាំដើម ដូច្នេះផ្ទាំងដែលកំពុងផ្តោតបង្ហាញតែរង្វង់ និងពន្លឺភ្លឹបភ្លែតរបស់វាប៉ុណ្ណោះ។", "suppressOnlyFocusedSurface": "នៅពេលបើក banner នៃការជូនដំណឹងនឹងត្រូវដកវិញដោយស្វ័យប្រវត្តិ តែនៅពេល Surface របស់វាជា Surface ដែលកំពុងផ្តោតយ៉ាងពិតប្រាកដប៉ុណ្ណោះ។ banner ដែលបានបញ្ជូនសម្រាប់ Surface ដែលមិនបានផ្តោតក្នុង workspace ដែលកំពុងបង្ហាញ នឹងបន្តបង្ហាញរហូតដល់អ្នកផ្តោតលើ Surface នោះ (ឬចុច/បិទវា) ជំនួសឱ្យការដកវិញនៅពេល workspace ក្លាយជាមើលឃើញ។ បិទ នឹងរក្សាឥរិយាបថដកវិញចាស់ដែលផ្អែកលើភាពមើលឃើញនៃ workspace។", + "suppressWhenAppFocused": "នៅពេលបើក cmux នឹងរំលង banner លើផ្ទៃតុ សម្រាប់ការជូនដំណឹងទាំងអស់ ខណៈពេល cmux ជាកម្មវិធីសកម្ម មិនត្រឹមតែសម្រាប់ផ្ទាំងដែលកំពុងផ្តោតប៉ុណ្ណោះទេ។ ការជូនដំណឹងនៅតែបង្ហាញក្នុងរបារចំហៀង សំឡេង និងពាក្យបញ្ជាផ្ទាល់ខ្លួននៅតែដំណើរការ ហើយការបញ្ជូនបន្តទៅទូរសព្ទមិនផ្លាស់ប្តូរទេ។ បិទ នឹងបន្តបង្ហាញ banner សម្រាប់ workspace និងផ្ទាំងផ្សេងទៀត ខណៈពេល cmux កំពុងត្រូវបានផ្តោត។", "agentIdleReminder": "ជូនដំណឹងនៅពេលភ្នាក់ងារមិនសកម្ម ហើយកំពុងរង់ចាំការបញ្ចូលរបស់អ្នក (ប្រហែល ៦០ វិនាទីបន្ទាប់ពីវេនបញ្ចប់)។ វាត្រូវបានទប់ស្កាត់ ខណៈការងារផ្ទៃខាងក្រោយពីវេនមុននៅតែរង់ចាំ ដើម្បីកុំឱ្យ build ឬ watcher ដែលកំពុងដំណើរការបង្កើតការជូនដំណឹងរង់ចាំខុស។", "soundOverrides": "ការបដិសេធសំឡេងជូនដំណឹងតាមភ្នាក់ងារ និងប្រភេទការជូនដំណឹង។ ក្រឡាដែលបាត់ប្រើ notifications.sound។", "soundOverride": "ការបដិសេធសំឡេងជូនដំណឹង។ default ប្រើ notifications.sound, none បិទការចាក់សំឡេង ហើយតម្លៃផ្សេងទៀតជ្រើសសំឡេងដែលភ្ជាប់មកជាមួយ ឬសំឡេងផ្ទាល់ខ្លួន។" diff --git a/web/messages/ko.json b/web/messages/ko.json index 0122748ad039..8974a997caf7 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "알림을 보낸 패널이 이미 포커스된 상태여도 알림음을 재생합니다. 기본값은 꺼짐이며, 포커스된 패널은 링과 깜박임만 표시합니다.", "suppressOnlyFocusedSurface": "활성화하면 알림 배너는 해당 서피스가 정확히 포커스된 서피스일 때만 자동으로 철회됩니다. 현재 표시 중인 workspace에서 포커스되지 않은 서피스로 전달된 배너는 workspace가 표시될 때 철회되지 않고, 해당 서피스에 포커스할 때까지(또는 클릭/해제할 때까지) 계속 표시됩니다. 끄면 기존의 workspace 표시 여부 기반 철회 동작이 유지됩니다.", + "suppressWhenAppFocused": "활성화하면 cmux가 활성 앱인 동안 포커스된 패널뿐 아니라 모든 알림에 대해 데스크톱 배너를 건너뜁니다. 알림은 계속 사이드바에 표시되고, 사운드와 사용자 지정 명령도 계속 실행되며, 휴대폰 전달은 바뀌지 않습니다. 끄면 cmux에 포커스가 있어도 다른 workspace와 패널의 배너가 계속 표시됩니다.", "agentIdleReminder": "에이전트가 유휴 상태로 입력을 기다릴 때(턴 종료 후 약 60초) 알림을 보냅니다. 이전 턴의 백그라운드 작업이 아직 남아 있는 동안에는 억제되므로 실행 중인 빌드나 감시자가 잘못된 대기 알림을 만들지 않습니다.", "soundOverrides": "에이전트 및 알림 유형별 알림 소리 재정의입니다. 비어 있는 셀은 notifications.sound를 사용합니다.", "soundOverride": "알림 소리 재정의입니다. default는 notifications.sound를 사용하고, none은 재생을 끄며, 나머지 값은 내장 또는 사용자 지정 소리를 선택합니다." diff --git a/web/messages/no.json b/web/messages/no.json index ab2f8e70ac80..191554ff8b51 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Spill av varsellyden selv når ruten som varslet allerede har fokus. Av som standard, så en rute med fokus viser bare ringen og blinket.", "suppressOnlyFocusedSurface": "Når dette er på, trekkes et varselbanner bare automatisk tilbake når overflaten er nøyaktig den fokuserte overflaten. Et banner levert for en ikke-fokusert overflate i det for øyeblikket synlige workspace blir værende til du fokuserer den overflaten (eller klikker/avviser det), i stedet for å bli trukket tilbake når workspace blir synlig. Av beholder den gamle tilbaketrekkingen basert på workspace-synlighet.", + "suppressWhenAppFocused": "Når dette er slått på, hopper cmux over skrivebordsbanneret for alle varsler mens cmux er den aktive appen, ikke bare for det fokuserte panelet. Varsler vises fortsatt i sidefeltet, lyden og den egendefinerte kommandoen kjøres fortsatt, og videresending til telefonen er uendret. Av fortsetter å vise bannere for andre workspaces og paneler mens cmux er i fokus.", "agentIdleReminder": "Varsle når en agent har vært inaktiv og venter på inndata (omtrent 60 sekunder etter at en tur er avsluttet). Varslingen undertrykkes mens bakgrunnsarbeid fra forrige tur fortsatt pågår, slik at en kjørende bygging eller overvåker ikke utløser et falskt ventevarsel.", "soundOverrides": "Sparsomme overstyringer av varslingslyd per agent og varslingstype. Manglende felt bruker notifications.sound.", "soundOverride": "En overstyring av varslingslyden. default bruker notifications.sound, none deaktiverer avspilling, og de øvrige verdiene velger en innebygd eller egendefinert lyd." diff --git a/web/messages/pl.json b/web/messages/pl.json index 12798161df2c..5c31d03fc0e8 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -1373,6 +1373,7 @@ "notifications": { "soundWhenFocused": "Odtwarzaj dźwięk powiadomienia nawet wtedy, gdy panel, który je wysłał, jest już aktywny. Domyślnie wyłączone, więc aktywny panel pokazuje tylko obwódkę i błysk.", "suppressOnlyFocusedSurface": "Gdy włączone, baner powiadomienia jest automatycznie wycofywany tylko wtedy, gdy jego Surface jest dokładnie tym Surface, który ma fokus. Baner dostarczony dla Surface bez fokusu w aktualnie widocznym workspace pozostaje wyświetlony, dopóki nie ustawisz fokusu na tym Surface (albo go nie klikniesz/odrzucisz), zamiast być wycofywany, gdy workspace stanie się widoczny. Wyłączone zachowuje dawne wycofywanie oparte na widoczności workspace.", + "suppressWhenAppFocused": "Po włączeniu cmux pomija baner na pulpicie dla każdego powiadomienia, gdy cmux jest aktywną aplikacją, a nie tylko dla panelu z fokusem. Powiadomienia nadal pojawiają się na pasku bocznym, dźwięk i niestandardowe polecenie nadal są uruchamiane, a przekazywanie na telefon się nie zmienia. Po wyłączeniu banery dla innych workspace'ów i paneli są nadal wyświetlane, gdy cmux ma fokus.", "agentIdleReminder": "Powiadamiaj, gdy agent jest bezczynny i czeka na dane wejściowe (około 60 sekund po zakończeniu tury). Powiadomienie jest wstrzymywane, dopóki trwa praca w tle z poprzedniej tury, aby uruchomiony build lub obserwator nie wywołał fałszywego alertu oczekiwania.", "soundOverrides": "Rzadkie zastąpienia dźwięku powiadomień dla agenta i typu alertu. Brakujące komórki używają notifications.sound.", "soundOverride": "Zastąpienie dźwięku powiadomienia. default używa notifications.sound, none wyłącza odtwarzanie, a pozostałe wartości wybierają dźwięk wbudowany lub niestandardowy." diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index f2277760c2d6..8e2386070f95 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Reproduz o som de notificação mesmo quando o painel que notificou já está em foco. Desativado por padrão, então um painel em foco mostra apenas o anel e o destaque.", "suppressOnlyFocusedSurface": "Quando ativado, um banner de notificação é retirado automaticamente apenas quando sua superfície é exatamente a superfície focada. Um banner entregue para uma superfície não focada no workspace atualmente visível permanece visível até você focar essa superfície (ou clicar nele/dispensá-lo), em vez de ser retirado quando o workspace se torna visível. Desativado preserva a retirada antiga baseada na visibilidade do workspace.", + "suppressWhenAppFocused": "Quando ativado, o cmux ignora o banner da área de trabalho em todas as notificações enquanto o cmux é o app ativo, não apenas no painel em foco. As notificações continuam aparecendo na barra lateral, o som e o comando personalizado continuam sendo executados, e o encaminhamento para o celular não muda. Desativado continua mostrando banners de outros workspaces e painéis enquanto o cmux está em foco.", "agentIdleReminder": "Notifique quando um agente estiver ocioso aguardando sua entrada (cerca de 60 segundos após o fim de um turno). A notificação é suprimida enquanto ainda houver trabalho em segundo plano do turno anterior, para que um build ou monitor em execução não gere um falso alerta de espera.", "soundOverrides": "Substituições esparsas do som de notificação por agente e tipo de alerta. Células ausentes usam notifications.sound.", "soundOverride": "Uma substituição do som de notificação. default usa notifications.sound, none desativa a reprodução e os demais valores selecionam um som integrado ou personalizado." diff --git a/web/messages/ru.json b/web/messages/ru.json index 1c409abeb370..9111c702b4be 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Воспроизводить звук уведомления, даже если панель, отправившая уведомление, уже в фокусе. По умолчанию выключено: панель в фокусе показывает только кольцо и вспышку.", "suppressOnlyFocusedSurface": "Когда включено, баннер уведомления автоматически убирается только тогда, когда его поверхность является именно сфокусированной поверхностью. Баннер, доставленный для несфокусированной поверхности в текущем видимом workspace, остаётся на экране, пока вы не сфокусируете эту поверхность (или не кликнете по нему/закроете его), вместо того чтобы убираться, когда workspace становится видимым. Выключено сохраняет прежнее поведение скрытия по видимости workspace.", + "suppressWhenAppFocused": "Если включено, cmux пропускает баннер на рабочем столе для всех уведомлений, пока cmux является активным приложением, а не только для панели в фокусе. Уведомления по-прежнему появляются на боковой панели, звук и пользовательская команда по-прежнему выполняются, а пересылка на телефон не меняется. Если выключено, баннеры для других workspace и панелей продолжают показываться, пока cmux в фокусе.", "agentIdleReminder": "Уведомлять, когда агент бездействует и ждёт вашего ввода (примерно через 60 секунд после завершения хода). Уведомление подавляется, пока ещё выполняется фоновая работа предыдущего хода, чтобы запущенная сборка или наблюдатель не вызвали ложное уведомление об ожидании.", "soundOverrides": "Разреженные переопределения звука уведомлений для каждого агента и типа оповещения. Для отсутствующих ячеек используется notifications.sound.", "soundOverride": "Переопределение звука уведомления. default использует notifications.sound, none отключает воспроизведение, а остальные значения выбирают встроенный или пользовательский звук." diff --git a/web/messages/th.json b/web/messages/th.json index 5622e003ff7b..33bd77dda86c 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -1373,6 +1373,7 @@ "notifications": { "soundWhenFocused": "เล่นเสียงแจ้งเตือนแม้ว่าแผงที่แจ้งเตือนจะอยู่ในโฟกัสอยู่แล้ว ปิดไว้เป็นค่าเริ่มต้น แผงที่อยู่ในโฟกัสจึงแสดงเพียงวงแหวนและการกะพริบเท่านั้น", "suppressOnlyFocusedSurface": "เมื่อเปิดใช้งาน แบนเนอร์การแจ้งเตือนจะถูกถอนออกอัตโนมัติเฉพาะเมื่อ Surface ของมันเป็น Surface ที่โฟกัสอยู่พอดีเท่านั้น แบนเนอร์ที่ส่งให้ Surface ที่ไม่ได้โฟกัสใน workspace ที่กำลังแสดงอยู่จะยังคงแสดงอยู่จนกว่าคุณจะโฟกัส Surface นั้น (หรือคลิก/ปิดมัน) แทนที่จะถูกถอนออกเมื่อ workspace แสดงขึ้น ปิด จะคงพฤติกรรมการถอนแบบเดิมที่อิงตามการมองเห็น workspace", + "suppressWhenAppFocused": "เมื่อเปิดใช้ cmux จะข้ามแบนเนอร์บนเดสก์ท็อปสำหรับการแจ้งเตือนทุกรายการขณะที่ cmux เป็นแอปที่ใช้งานอยู่ ไม่ใช่เฉพาะบานหน้าต่างที่โฟกัสอยู่ การแจ้งเตือนยังคงแสดงในแถบด้านข้าง เสียงกับคำสั่งกำหนดเองยังคงทำงาน และการส่งต่อไปยังโทรศัพท์ไม่เปลี่ยนแปลง เมื่อปิดจะยังแสดงแบนเนอร์สำหรับ workspace และบานหน้าต่างอื่นขณะที่ cmux อยู่ในโฟกัส", "agentIdleReminder": "แจ้งเตือนเมื่อเอเจนต์ไม่ได้ใช้งานและกำลังรออินพุตของคุณ (ประมาณ 60 วินาทีหลังจบรอบ) ระบบจะระงับการแจ้งเตือนขณะที่งานเบื้องหลังจากรอบก่อนหน้ายังค้างอยู่ เพื่อไม่ให้บิลด์หรือ watcher ที่กำลังทำงานแจ้งเตือนการรอแบบผิดพลาด", "soundOverrides": "การแทนที่เสียงการแจ้งเตือนแบบแยกตามเอเจนต์และประเภทการแจ้งเตือน ช่องที่ไม่มีค่าจะใช้ notifications.sound", "soundOverride": "การแทนที่เสียงการแจ้งเตือน default ใช้ notifications.sound, none ปิดการเล่น และค่าอื่นจะเลือกเสียงในตัวหรือเสียงกำหนดเอง" diff --git a/web/messages/tr.json b/web/messages/tr.json index 076896578d63..3ce45659168c 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "Bildirimi gönderen bölme zaten odaktayken bile bildirim sesini çal. Varsayılan olarak kapalıdır; odaktaki bölme yalnızca halkasını ve parlamasını gösterir.", "suppressOnlyFocusedSurface": "Etkinleştirildiğinde, bir bildirim banner'ı yalnızca yüzeyi tam olarak odaklanmış yüzey olduğunda otomatik olarak geri çekilir. Şu anda görünür olan workspace içinde odaklanmamış bir yüzey için gönderilen bir banner, workspace görünür olduğunda geri çekilmek yerine, siz o yüzeye odaklanana (ya da tıklayana/kapatana) kadar görünür kalır. Kapalı, eski workspace görünürlüğüne dayalı geri çekme davranışını korur.", + "suppressWhenAppFocused": "Etkinleştirildiğinde cmux, etkin uygulama olduğu sürece yalnızca odaklanılan bölme için değil tüm bildirimler için masaüstü başlığını atlar. Bildirimler kenar çubuğunda görünmeye devam eder, ses ve özel komut çalışmaya devam eder ve telefona yönlendirme değişmez. Kapalıyken cmux odaktayken diğer workspace'ler ve bölmeler için başlıklar gösterilmeye devam eder.", "agentIdleReminder": "Bir ajan boşta olup girişinizi beklediğinde (tur sona erdikten yaklaşık 60 saniye sonra) bildirim gönderin. Önceki turdan kalan arka plan işi devam ederken bastırılır; böylece çalışan bir derleme veya izleyici yanlış bir bekleme uyarısı oluşturmaz.", "soundOverrides": "Ajan ve uyarı türüne göre seyrek bildirim sesi geçersiz kılmaları. Eksik hücreler notifications.sound kullanır.", "soundOverride": "Bir bildirim sesi geçersiz kılması. default, notifications.sound kullanır; none oynatmayı devre dışı bırakır; diğer değerler yerleşik veya özel bir ses seçer." diff --git a/web/messages/uk.json b/web/messages/uk.json index e67eb37c732c..59b481f018c7 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -1352,6 +1352,7 @@ "notifications": { "soundWhenFocused": "Відтворювати звук сповіщення, навіть якщо панель, що надіслала сповіщення, уже у фокусі. Типово вимкнено: панель у фокусі показує лише кільце та спалах.", "suppressOnlyFocusedSurface": "Коли увімкнено, банер сповіщення автоматично прибирається лише тоді, коли його поверхня є саме сфокусованою поверхнею. Банер, доставлений для несфокусованої поверхні в поточному видимому workspace, залишається показаним, доки ви не сфокусуєте цю поверхню (або не клацнете по ньому/закриєте його), замість того щоб бути прибраним, коли workspace стає видимим. Вимкнено зберігає попередню поведінку прибирання за видимістю workspace.", + "suppressWhenAppFocused": "Якщо ввімкнено, cmux пропускає банер на робочому столі для всіх сповіщень, поки cmux є активною програмою, а не лише для панелі у фокусі. Сповіщення й надалі з'являються на бічній панелі, звук і власна команда й надалі виконуються, а пересилання на телефон не змінюється. Якщо вимкнено, банери для інших workspace і панелей і далі показуються, поки cmux у фокусі.", "agentIdleReminder": "Сповіщати, коли агент неактивний і чекає на ваше введення (приблизно через 60 секунд після завершення ходу). Сповіщення пригнічується, поки триває фонова робота попереднього ходу, щоб запущена збірка або спостерігач не створювали хибне сповіщення очікування.", "soundOverrides": "Розріджені перевизначення звуку сповіщень для кожного агента й типу сповіщення. Порожні клітинки використовують notifications.sound.", "soundOverride": "Перевизначення звуку сповіщення. default використовує notifications.sound, none вимикає відтворення, а інші значення вибирають вбудований або власний звук." diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 16784aa66093..0b013a2431a4 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "即使发出通知的窗格已处于焦点状态,也播放通知声音。默认关闭,因此处于焦点的窗格只显示光环和闪烁。", "suppressOnlyFocusedSurface": "启用后,仅当通知横幅的 Surface 恰好是当前聚焦的 Surface 时,该横幅才会自动撤回。对于当前可见 workspace 中未聚焦的 Surface 所投递的横幅,在你聚焦该 Surface(或点击/关闭它)之前会一直显示,而不会在 workspace 变为可见时被撤回。关闭则保留旧的基于 workspace 可见性的撤回行为。", + "suppressWhenAppFocused": "启用后,只要 cmux 是当前活跃的应用,cmux 就会对所有通知跳过桌面横幅,而不仅限于获得焦点的窗格。通知仍会显示在侧边栏中,声音和自定义命令也仍会运行,转发到手机的行为不变。关闭时,即使 cmux 获得焦点,其他 workspace 和窗格的横幅也会继续显示。", "agentIdleReminder": "当代理空闲并等待你的输入时(回合结束后约 60 秒)发送通知。只要上一回合的后台工作仍在等待,就会抑制通知,避免正在运行的构建或监视器触发错误的等待提醒。", "soundOverrides": "按代理和提醒类型设置的稀疏通知声音覆盖。缺少的单元格使用 notifications.sound。", "soundOverride": "通知声音覆盖。default 使用 notifications.sound,none 禁用播放,其余值选择内置或自定义声音。" diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 49331a3a7307..fd3e0b58db52 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -1372,6 +1372,7 @@ "notifications": { "soundWhenFocused": "即使發出通知的窗格已在焦點中,也播放通知聲音。預設關閉,因此焦點中的窗格只會顯示光環與閃爍。", "suppressOnlyFocusedSurface": "啟用後,只有當通知橫幅的 Surface 正好是目前聚焦的 Surface 時,該橫幅才會自動撤回。對於目前可見 workspace 中未聚焦的 Surface 所投遞的橫幅,在你聚焦該 Surface(或點擊/關閉它)之前會持續顯示,而不會在 workspace 變為可見時被撤回。關閉則保留舊有的基於 workspace 可見性的撤回行為。", + "suppressWhenAppFocused": "啟用後,只要 cmux 是目前使用中的 App,cmux 就會對所有通知略過桌面橫幅,而不只限於取得焦點的窗格。通知仍會顯示在側邊欄中,音效與自訂指令也仍會執行,轉送到手機的行為不變。關閉時,即使 cmux 取得焦點,其他 workspace 與窗格的橫幅也會繼續顯示。", "agentIdleReminder": "當代理程式閒置並等待你的輸入時(回合結束後約 60 秒)傳送通知。只要上一回合的背景工作仍在等待,就會抑制通知,避免正在執行的建置或監看程式觸發錯誤的等待提醒。", "soundOverrides": "依代理程式和提醒類型設定的稀疏通知聲音覆寫。缺少的儲存格會使用 notifications.sound。", "soundOverride": "通知聲音覆寫。default 使用 notifications.sound,none 停用播放,其餘值選擇內建或自訂聲音。"