Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ final class AppServices {
notifications.start(services: self)
keyRouter.onTyping = { [weak self] window in self?.notifications.noteTyping(in: window) }
(NSApp as? CmuxApplication)?.mouseDownObserver = { [weak self] event in
self?.notifications.noteMouseDown(in: event.window)
self?.notifications.noteMouseDown(in: event.window, at: event.locationInWindow)
self?.browserHost?.noteInput(event)
// A click anywhere ends link hints and a waiting chord (it may move the keyboard).
self?.linkHints.cancel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ extension NotificationCenterService {
var marks: [LayoutPaneID: AttentionMark] = [:]
for screen in workspace.screens {
for pane in screen.panes {
// An agent chat whose turn ended rings until the person goes into it.
if let finished = pane.tabs.compactMap({ agentFinished[$0.id] }).max() {
marks[LayoutPaneID(pane.id)] = AttentionMark(color: preferences.sources[.agent]?.color, generation: finished)
continue
}
let unread = pane.tabs.filter(\.hasUnread)
guard let newest = unread.max(by: { ($0.notification?.notification.rawValue ?? 0) < ($1.notification?.notification.rawValue ?? 0) }) else { continue }
let generation = newest.notification?.notification.rawValue ?? 1
Expand All @@ -171,6 +176,20 @@ extension NotificationCenterService {
return marks
}

/// Of the panes in `removed` (their mark just went away), those read because
/// the person went into them: their ring blinks out. Forgets the entry
/// reads of every tab in `workspace`, so a later mark starts clean.
func takeEntryReads(removed: Set<LayoutPaneID>, in workspace: WorkspaceModel) -> Set<LayoutPaneID> {
guard !entryReads.isEmpty else { return [] }
var entered: Set<LayoutPaneID> = []
for pane in workspace.screens.flatMap(\.panes) {
let read = pane.tabs.contains { entryReads.contains($0.id) }
if read, removed.contains(LayoutPaneID(pane.id)) { entered.insert(LayoutPaneID(pane.id)) }
for tab in pane.tabs { entryReads.remove(tab.id) }
}
return entered
}

/// Whether `workspace` draws an attention ring now (the Dismiss Highlight item shows only then).
func hasHighlight(_ workspace: WorkspaceModel) -> Bool {
!attentionMarks(for: workspace).isEmpty
Expand All @@ -187,6 +206,7 @@ extension NotificationCenterService {
dismissedHighlights = [:]
dismissedHighlightSession = session
}
for tab in workspace.screens.flatMap(\.panes).flatMap(\.tabs) where agentFinished[tab.id] != nil { agentFinished[tab.id] = nil }
guard newest > (dismissedHighlights[workspace.id] ?? 0) else { return }
dismissedHighlights[workspace.id] = newest
note("highlight dismissed workspace=\(workspace.id) through=\(newest)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ extension NotificationCenterService {
/// tab (key window, cmux active) is seen at once;
/// - an unread notification on a viewed tab is read at once, on every
/// path that marks it unread (an arrival, a held delivery, a resync),
/// as a focus on that tab would read it (`interacted(.focus)`, by the
/// dismissal policy).
/// when the dismissal policy reads on focus (`interacted(.focus)`; the
/// default `click` keeps it until the pane is clicked or typed in).
/// Focus, typing, clicks and opens see the rest through `interacted`. An
/// agent chat's turns are acpmux's (`unread`, cx-n8ou): its pane says
/// whether it shows the chat.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ final class NotificationCenterService {
/// The tabs viewed at the last viewed-tab pass: a tab marked unread
/// (Mark Tab Unread) clears when it comes into view, not while it stays.
@ObservationIgnored var viewedTabIDs: Set<String> = []
/// Tabs read because the person went into their pane (a click, a key, an
/// open), until the layout takes them for the ring's dismiss flash.
@ObservationIgnored var entryReads: Set<String> = []
/// Agent chat tabs whose turn ended, by the generation of their ring: the pane rings until
/// the person goes into it (acpmux reads a turn that ends in a shown chat at once, so no
/// notification marks it). Observed: the attention marks follow it.
var agentFinished: [String: UInt64] = [:]
/// Ring generations for ``agentFinished``, above any notification id, so a new finish
/// restarts the ring's arrival and never equals a notification's mark.
@ObservationIgnored private var agentFinishGeneration: UInt64 = 1 << 62
@ObservationIgnored let desktop = DesktopNotifier()
/// A passkey request's banner was clicked (its request id): the passkey hand-off answers it
/// (plans/cmux-next/agent-browser-passkey-DESIGN.md 3.2).
Expand Down Expand Up @@ -91,6 +101,7 @@ final class NotificationCenterService {

func start(services: AppServices) {
self.services = services
AgentTurnStateStore.shared.onTurnEnded = { [weak self] session in self?.agentTurnEnded(session: session) }
ProgramStatusSeenStore.shared.persist(to: .standard)
desktopPostingEnabled = !services.environment.showcase
let feed = services.feed
Expand Down Expand Up @@ -172,12 +183,37 @@ final class NotificationCenterService {
interacted(.keystroke, tabID: tab)
}

/// A mouse-down landed in `window` (after AppKit dispatched it).
func noteMouseDown(in window: NSWindow?) {
guard let tab = focusedTab(in: window) else { return }
/// A mouse-down landed in `window` at `location` (window coordinates,
/// after AppKit dispatched it): reads the pane under the pointer, never
/// another one, so a ring stays until its own pane is clicked. A click
/// outside the panes (sidebar, titlebar, tab bar gaps) reads nothing.
func noteMouseDown(in window: NSWindow?, at location: NSPoint) {
guard let tab = clickedTab(in: window, at: location) else { return }
interacted(.click, tabID: tab)
}

/// The selected tab of the pane under `location` in `window`. A child
/// window (a Chromium page's) is hit-tested in the workspace window it
/// belongs to. A window outside any workspace window, or one showing a top
/// page, reads the tab that has the keyboard, which the click gave it.
func clickedTab(in window: NSWindow?, at location: NSPoint) -> String? {
guard let services, let window else { return nil }
let host = CmuxApplication.accessibilityWindow(for: window) ?? window
guard let controller = services.windows.controllers.first(where: { $0.window === host }),
controller.state.page == nil, let content = controller.content else { return focusedTab(in: window) }
let inHost = host === window ? location : host.convertPoint(fromScreen: window.convertPoint(toScreen: location))
let root = content.layoutView
let point = root.convert(inHost, from: nil)
let hits = content.layoutModel.visiblePanes.filter { root.frame(of: $0)?.contains(point) == true }
// Overlapping panes (a docked column over the strip): the click focused the front one. A
// click there that moved no focus reads nothing rather than a guess.
let focused = controller.focus.state.pane
guard let pane = hits.first(where: { $0.rawValue == focused }) ?? (hits.count == 1 ? hits.first : nil),
let model = content.paneModel(key: pane.rawValue), !model.tabs.isEmpty else { return nil }
let selected = controller.state.selection.selection(in: model.id)
return (model.tabs.first { $0.id == selected } ?? model.tabs[min(max(model.defaultTabIndex, 0), model.tabs.count - 1)]).id
}

/// A window's settled focus changed (window key, app active, workspace or
/// keyboard target): the viewed tab counts as focused while the window is
/// key and cmux is active. The viewed-tab rules read it (one path for a
Expand Down Expand Up @@ -225,9 +261,15 @@ final class NotificationCenterService {
// Any look at the tab sees its OSC 7501 done and error records and an
// agent chat's completed turn (client view state; the owners keep the facts).
ProgramStatusSeenStore.shared.markSeen(tab)
if agentFinished[tab.id] != nil, NotificationPolicy(prefs: preferences).clears(trigger, for: .agent) {
note("\(trigger.rawValue) read agent turn \(tab.id)")
agentFinished[tab.id] = nil
if [.click, .keystroke, .open].contains(trigger) { entryReads.insert(tab.id) }
}
guard tab.hasUnread else { return }
guard NotificationPolicy(prefs: preferences).clears(trigger, for: source(of: tab)) else { return }
note("\(trigger.rawValue) read \(tab.id)")
if [.click, .keystroke, .open].contains(trigger) { entryReads.insert(tab.id) }
acknowledge(tab)
}

Expand All @@ -246,6 +288,7 @@ final class NotificationCenterService {
/// Acknowledges `tab` in the daemon (a dismiss verb, or a policy trigger).
func acknowledge(_ tab: TabModel) {
note("ack \(tab.id)")
if agentFinished[tab.id] != nil { agentFinished[tab.id] = nil }
timeouts.removeValue(forKey: tab.id)?.cancel()
approvals.forget(tab: tab.id, retire: true)
finishedBursts.read(tab: tab.id)
Expand All @@ -262,6 +305,18 @@ final class NotificationCenterService {
}
}

/// An agent chat's turn ended: ring the panes that show its tab until the person goes in.
func agentTurnEnded(session: String) {
guard let services else { return }
for workspace in services.daemon.store.workspaces {
for tab in workspace.screens.flatMap(\.panes).flatMap(\.tabs) where tab.agentSession?.session == session {
agentFinishGeneration &+= 1
agentFinished[tab.id] = agentFinishGeneration
note("agent turn ended \(tab.id)")
}
}
}

// MARK: Arrival

private func arrived(_ notification: DaemonNotification) {
Expand Down Expand Up @@ -303,11 +358,17 @@ final class NotificationCenterService {
acknowledge(located.tab)
return
}
// A turn that finished in front of the user is already seen (default call, cx-slk1h): the
// history keeps it, read. An agent that asks or fails stays unread even there, and so does
// an earlier ask or error the tab still holds (acknowledging reads the whole tab).
// A turn that finished in front of the user is already seen (cx-slk1h) when the dismissal
// reads a pane on sight (`focus`): the history keeps it, read. Under the default `click`
// (2026-10-20) its ring stays until the pane is clicked or typed in. An agent that asks or
// fails stays unread even there, and so does an earlier ask or error the tab still holds
// (acknowledging reads the whole tab).
let shown = visibility ?? self.visibility(of: located)
if notification.isFinishedTurn, shown.isVisible, !located.tab.holdsUnreadAsk(besides: notification) {
guard NotificationPolicy(prefs: preferences).clears(.focus, for: source) else {
// Seen finishing, so no banner; the ring waits for a click in the pane.
return keepSeenUnread(located, decision: decision, note: "finished turn visible: kept unread, no banner")
}
note("finished turn visible: read at once")
acknowledge(located.tab)
return
Expand All @@ -323,12 +384,26 @@ final class NotificationCenterService {
deliver(notification, source: source, located: located, decision: decision, program: program, visibility: shown)
}

/// An arrival the user saw land, kept unread for a click in its pane: it posts nothing, the
/// feed hears it did not alert here (so it is not mirrored to the iPhone), and the timeout
/// dismissal still gets its deadline.
private func keepSeenUnread(_ located: LocatedTab, decision: NotificationPolicy.Decision, note line: String) {
note(line)
feedDriver?.noteArrival(terminal: located.tab.terminalResourceID?.rawValue, alerted: false)
if let seconds = decision.timeout { scheduleTimeout(seconds, tabID: located.tab.id) }
}

/// The rest of an arrival once its OSC 7501 record (if any) is known.
func deliver(_ notification: DaemonNotification, source: NotificationSource, located: LocatedTab,
decision: NotificationPolicy.Decision, program: ProgramStatusNotification?,
visibility: TerminalVisibility? = nil) {
// An OSC 7501 done for a terminal the user can see is read at once.
// An OSC 7501 done for a terminal the user can see posts nothing (cx-kxa2). It is read at
// once when the dismissal reads a pane on sight (`focus`); under `click` its ring stays
// until the pane is clicked or typed in.
if let program, !program.notifies(visibility: visibility ?? self.visibility(of: located)) {
guard NotificationPolicy(prefs: preferences).clears(.focus, for: source) else {
return keepSeenUnread(located, decision: decision, note: "program status \(program.reason.rawValue) visible: kept unread, no banner")
}
note("program status \(program.reason.rawValue) visible: read at once")
acknowledge(located.tab)
return
Expand Down Expand Up @@ -393,6 +468,7 @@ final class NotificationCenterService {
}

private func scheduleTimeout(_ seconds: Double, tabID: String) {
note("timeout \(tabID) in \(seconds)s")
let timer = timeouts[tabID] ?? DemandTimer(owner: "notifications.timeout", clock: clock)
timeouts[tabID] = timer
timer.schedule(after: .seconds(seconds)) { @MainActor [weak self] in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,12 @@ final class WorkspaceContentController: LayoutPaneContentProvider {
attentionObservation = Task { [weak self] in
for await marks in ObservationStream({ notifications.attentionMarks(for: workspace) }) {
guard let self else { return }
if self.layoutModel.attention != marks { self.layoutModel.attention = marks }
guard self.layoutModel.attention != marks else { continue }
// A ring read by going into its pane blinks out (classic's dismiss flash); one
// cleared any other way fades. Set first, so the change that removes it sees it.
let removed = Set(self.layoutModel.attention.keys).subtracting(marks.keys)
self.layoutModel.attentionReadByEntry = notifications.takeEntryReads(removed: removed, in: workspace)
self.layoutModel.attention = marks
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ public final class AgentTurnStateStore {
/// The watch is up and the states are acpmux's: false before its first page and after it
/// closes, when a chat with no state may still be working.
public var watching = false
/// A session's running turn ended (working to anything else; the watch closing is not an
/// end). The app rings the chat's pane until the person goes into it, as a terminal agent's
/// notification does: acpmux reads a turn that ends in a shown chat at once (cx-n8ou).
@ObservationIgnored public var onTurnEnded: ((String) -> Void)?

public init() {}

Expand Down Expand Up @@ -294,6 +298,7 @@ public final class AgentTurnStateStore {
source: String = AgentTurnStateStore.localSource) {
let held = record(session)
held.source = source
if recordsTimeline, held.state == .working, state != .working { onTurnEnded?(session) }
if recordsTimeline {
let nowMs = UInt64((Date().timeIntervalSince1970 * 1000).rounded())
for event in AgentTimeline.chatEvents(from: held.state, held.detail, to: state, detail, nowMs: nowMs) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ public import AppKit
/// in the choice list, so TunableStore drops it at load and the look is the
/// `subtle` default.
public nonisolated enum AttentionHighlightLook: String, Sendable, CaseIterable, Hashable, TunableChoice {
/// The default. A blink or pulse runs, then the ring rests faint; the steady
/// ring (opt-in; blink is the default style) swells in once and rests clearly visible.
/// The default. The steady ring (the default style) swells in once and rests clearly
/// visible; a blink or pulse runs, then the ring rests faint.
case subtle
/// The ring flashes and rests at full strength (the look before cx-epgo).
case strong
Expand All @@ -35,7 +35,7 @@ public nonisolated enum AttentionHighlightLook: String, Sendable, CaseIterable,
}
}

/// The steady ring's opacity (an opt-in style; blink is the default): clearly visible,
/// The steady ring's opacity (the default style): clearly visible,
/// since it has no blink to catch the eye (Leo, 2026-10-14).
public var steadyOpacity: Float {
switch self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ public nonisolated enum AttentionStyle: String, Hashable, Sendable, CaseIterable
/// `notifications.attention.*` in cmux.json: the pane attention ring and
/// whether the tab and the sidebar row show the unread mark too.
public nonisolated struct AttentionSettings: Hashable, Sendable {
public var style: AttentionStyle = .blink
/// `steady` (Austin, 2026-10-20): a blue ring just appears, as in cmux classic; the blink
/// plays when the person goes into the pane and the ring goes away
/// (`MotionGate.attentionDismissAnimation`).
public var style: AttentionStyle = .steady
/// nil takes the theme's attention color (`Palette.attention`).
public var color: ThemeRGB?
public var width: CGFloat = 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,27 @@ extension MotionGate {
}
}
}

extension MotionGate {
/// The flash a ring plays as it goes away because the person went into its pane (cmux
/// classic's dismiss flash): `blinkCount` blinks at the peak, ending with no ring. Static
/// status indicators (cx-sya4n) and Reduce Motion get one fade out; no ring style, or
/// animations off, gets none.
public func attentionDismissAnimation(_ settings: AttentionSettings,
look: AttentionHighlightLook = AttentionHighlightLook.tunable.value) -> CAAnimation? {
guard animatesFades, settings.style != .none else { return nil }
let peak = look.peakOpacity
let flash = CAKeyframeAnimation(keyPath: "opacity")
guard animatesLoops, DesignSettings.shared.statusIndicator.animation != .static else {
flash.values = [peak, 0]
flash.duration = duration(.fadeIn)
return flash
}
let count = min(max(settings.blinkCount, AttentionSettings.blinkRange.lowerBound), AttentionSettings.blinkRange.upperBound)
var values: [Float] = []
for _ in 0..<count { values += [peak, 0] }
flash.values = values
flash.duration = MotionLoop.flash.period / 2 * Double(count) * speed.timeScale
return flash
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ public final class LayoutModel {
/// Panes that need attention (an unread notification), with the mark
/// the overlay draws. Set by the App from daemon unread state.
public var attention: [PaneID: AttentionMark] = [:]
/// Panes whose mark the last `attention` change removed because the person
/// went into the pane (a click, a key, an open): their ring blinks out, as
/// cmux classic's dismiss flash did. Set by the App just before `attention`.
public var attentionReadByEntry: Set<PaneID> = []

/// Panes whose frame currently intersects the visible viewport of the
/// active screen. Hosted views stay alive while not visible; use this to
Expand Down
Loading