# How cmux Handles Notification Rings and Tab Badge Notifications: A Complete Technical Breakdown

> Discover how cmux syncs notification rings and tab badges using its TerminalNotificationStore for seamless UI updates across your applications. Get the technical breakdown.

- Repository: [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux)
- Tags: deep-dive
- Published: 2026-03-29

---

**cmux synchronizes three visual cues—menu bar badges, workspace tab badges, and blue notification rings—through a centralized TerminalNotificationStore that tracks unread counts and triggers UI updates across AppDelegate, BrowserPanelView, and GhosttyTerminalView.**

cmux, the terminal multiplexer built by manaflow-ai, provides sophisticated notification management through persistent visual cues. Understanding how cmux handles notification rings and tab badge notifications requires examining its centralized notification store and the coordinated UI updates that keep users informed of unread activity across workspaces and terminal surfaces.

## Understanding cmux's Notification Architecture

### The Centralized TerminalNotificationStore

Located in [`Sources/TerminalNotificationStore.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/TerminalNotificationStore.swift), this store acts as the single source of truth for all notification state. When any component calls `addNotification(tabId:surfaceId:title:subtitle:body:)`, the store performs several atomic operations:

- Removes previous notifications for the same tab and surface combination
- Updates `indexes.unreadCount` (global) and `indexes.unreadCountByTabId[tabId]` (per-tab)
- Records the notification in the `notifications` array
- Forwards the notification to macOS (unless the user currently focuses the target pane)

### Unread Count APIs

The store exposes two critical methods for UI components:

```swift
func unreadCount(forTabId tabId: UUID) -> Int          // per-tab count
var unreadCount: Int { indexes.unreadCount }          // global total

```

These counters drive all badge and ring visibility decisions throughout the application.

## Global Badge Notifications (Menu Bar and Dock)

### Menu Bar Badge Formatting

In [`Sources/AppDelegate.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/AppDelegate.swift), the `MenuBarBadgeLabelFormatter.badgeText(for:)` method generates compact badge strings:

```swift
static func badgeText(for unreadCount: Int) -> String? {
    guard unreadCount > 0 else { return nil }
    return unreadCount > 9 ? "9+" : String(unreadCount)
}

```

This formatting keeps the menu bar icon legible by capping displayed counts at "9+".

### Dock Badge Updates

The `TerminalNotificationStore` computes the dock badge separately via `dockBadgeLabel`, which caps counts at "99+" for the larger dock tile. Both badges update automatically when `AppDelegate.updateStatusMenu` runs and `MenuBarIconRenderer.makeImage` regenerates the menu bar icon. Additionally, [`Sources/Update/UpdateTitlebarAccessory.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/Update/UpdateTitlebarAccessory.swift) displays small badges on the title bar for update-related notifications.

## Workspace Tab Badge Notifications

### Rendering Per-Tab Unread Counts

Each workspace tab renders its badge in [`Sources/Panels/BrowserPanelView.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/Panels/BrowserPanelView.swift). When constructing tab rows, the view queries `notificationStore.unreadCount(forTabId:)` and conditionally displays a small rounded badge:

```swift
if let badge = item.trailingBadgeText {
    Text(badge)
        .font(.system(size: 9.5, weight: .medium))
        .foregroundStyle(badgeTextColor)
        .background(RoundedRectangle(cornerRadius: 7).fill(badgeBackgroundColor))
}

```

The badge view only appears when `unreadCount > 0`.

### Customizing Badge Appearance

Users configure the badge color through the `notificationBadgeColor` setting in Preferences → Workspace. `BrowserPanelView` reads this value to set `badgeBackgroundColor`, applying it to the `RoundedRectangle` background behind the count text.

## The Unread Notification Ring

### Ring Implementation in GhosttyTerminalView

The blue notification ring lives in [`Sources/GhosttyTerminalView.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/GhosttyTerminalView.swift) as an overlay system consisting of `notificationRingOverlayView` (a `GhosttyFlashOverlayView`) and `notificationRingLayer` (a `CAShapeLayer`). The layer configures once with system blue stroke color and specific line width metrics, then remains hidden by default:

```swift
notificationRingOverlayView = GhosttyFlashOverlayView(frame: .zero)
notificationRingLayer = CAShapeLayer()
notificationRingLayer.opacity = 0

```

### Visibility Logic and Focus State

`TerminalPanelView` determines ring visibility through the boolean expression:

```swift
showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled

```

Where `hasUnreadNotification` queries `notificationStore.hasUnreadNotification(forTabId:surfaceId:)`. When this value changes, `hostedView.setNotificationRing(visible:)` executes, toggling `isHidden` and animating the layer's `opacity`:

```swift
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)

```

The ring appears only when a pane contains unread notifications but lacks user focus.

## Practical Implementation Examples

### Adding Notifications Programmatically

To trigger notifications from socket commands or internal events:

```swift
TerminalNotificationStore.shared.addNotification(
    tabId: tabId,
    surfaceId: surfaceId,
    title: "Build finished",
    subtitle: "Success",
    body: "Your project compiled without errors."
)

```

This single call cascades through all UI components, updating badges and rings automatically.

### Manual Ring Control

For testing or custom behaviors, directly control the ring visibility:

```swift
// Show the blue notification ring
hostedView.setNotificationRing(visible: true)
// Hide the notification ring
hostedView.setNotificationRing(visible: false)

```

### Querying Current Badge State

Check global unread counts for custom UI components:

```swift
let unread = TerminalNotificationStore.shared.unreadCount
let badge = MenuBarBadgeLabelFormatter.badgeText(for: unread)  // Returns "9+" or nil

```

## Summary

- **TerminalNotificationStore** ([`Sources/TerminalNotificationStore.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/TerminalNotificationStore.swift)) serves as the centralized authority for all notification state, maintaining both global and per-tab unread counters.
- **Global badges** cap at "9+" for the menu bar and "99+" for the dock, formatted by `MenuBarBadgeLabelFormatter` in [`Sources/AppDelegate.swift`](https://github.com/manaflow-ai/cmux/blob/main/Sources/AppDelegate.swift).
- **Tab badges** render conditionally in `BrowserPanelView` only when `unreadCount > 0`, with customizable colors via `notificationBadgeColor`.
- **Notification rings** appear as blue borders around unfocused terminal surfaces, implemented in `GhosttyTerminalView` through `CAShapeLayer` overlays controlled by `TerminalPanelView` focus state.
- All visual cues update atomically when `addNotification` modifies the store's `indexes` struct.

## Frequently Asked Questions

### How does cmux track unread notifications across multiple tabs?

cmux maintains separate counters in `TerminalNotificationStore` using the `indexes.unreadCountByTabId` dictionary, which maps tab UUIDs to integer counts. When `addNotification` fires, it increments both the specific tab's counter and the global `indexes.unreadCount`, allowing `BrowserPanelView` to query per-tab values via `unreadCount(forTabId:)` while `AppDelegate` accesses the global total.

### What triggers the blue notification ring to appear?

The notification ring activates when `TerminalPanelView` evaluates `hasUnreadNotification && notificationPaneRingEnabled` as true, meaning the specific pane contains unread notifications and the user has enabled ring notifications in settings. The ring disappears immediately when the pane gains focus, as `TerminalPanelView` updates `showsUnreadNotificationRing` and calls `setNotificationRing(visible:)` on the hosted view.

### Can the notification badge colors be customized in cmux?

Yes. Users modify the `notificationBadgeColor` setting in Preferences → Workspace, which `BrowserPanelView` reads when rendering tab badges. This color applies to the `RoundedRectangle` background behind the unread count text, allowing visual distinction between different notification types or personal preferences.

### Is there a limit to the number displayed on cmux badges?

Yes. The menu bar badge caps at "9+" for space constraints, implemented in `MenuBarBadgeLabelFormatter.badgeText(for:)`. The dock badge displays up to "99+" using `TerminalNotificationStore.dockBadgeLabel`. Tab badges show exact counts without artificial caps since they display within the larger workspace interface.