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

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, 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:

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)

In Sources/AppDelegate.swift, the MenuBarBadgeLabelFormatter.badgeText(for:) method generates compact badge strings:

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 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. When constructing tab rows, the view queries notificationStore.unreadCount(forTabId:) and conditionally displays a small rounded badge:

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 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:

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

Visibility Logic and Focus State

TerminalPanelView determines ring visibility through the boolean expression:

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:

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:

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:

// 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:

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

Summary

  • TerminalNotificationStore (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.
  • 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →