From 656786fb71a22bba73e2916aa566cbf9aa557cf1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 20 Mar 2026 20:20:54 -0700 Subject: [PATCH] Fix tmux notification attention routing --- CLI/cmux.swift | 181 ++++++++++- Sources/ContentView.swift | 239 ++++++++++++++ Sources/GhosttyTerminalView.swift | 65 ++-- Sources/Panels/BrowserPanel.swift | 3 +- Sources/Panels/MarkdownPanel.swift | 3 +- Sources/Panels/Panel.swift | 164 +++++++++- Sources/Panels/TerminalPanel.swift | 26 +- Sources/TabManager.swift | 12 +- Sources/TerminalNotificationStore.swift | 46 ++- Sources/Workspace.swift | 75 ++++- Sources/WorkspaceContentView.swift | 401 +++++++++++++++++++++++- 11 files changed, 1151 insertions(+), 64 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0f9246cf..5c0a2fd2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1863,12 +1863,33 @@ struct CMUXCLI { case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") - let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let explicitWorkspaceArg = tfWsFlag + let callerWorkspaceArg = windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") + let callerSurfaceArg = explicitWorkspaceArg == nil && windowId == nil + ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + let wsId = try { + if explicitWorkspaceArg != nil { + return try normalizeWorkspaceHandle(workspaceArg, client: client) + } + return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) + }() if let wsId { params["workspace_id"] = wsId } - let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + let sfId = try { + if explicitSurfaceArg != nil { + return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + } + guard let wsId else { return nil } + return try resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: wsId, + client: client + ) + }() if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.trigger_flash", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) @@ -2062,12 +2083,31 @@ struct CMUXCLI { let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" - let notifyWsFlag = optionValue(commandArgs, name: "--workspace") - let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace") + let callerWorkspaceArg = windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") + let callerSurfaceArg = explicitWorkspaceArg == nil && windowId == nil + ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg - let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client) - let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) + let targetWorkspace = try { + if explicitWorkspaceArg != nil { + return try resolveWorkspaceId(workspaceArg, client: client) + } + return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) + }() + let targetSurface = try { + if explicitSurfaceArg != nil { + return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) + } + return try resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: targetWorkspace, + client: client + ) + }() let payload = "\(title)|\(subtitle)|\(body)" let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) @@ -10482,13 +10522,7 @@ struct CMUXCLI { } private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { - if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { - let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) - if probe != nil { - return candidate - } - } - return try resolveWorkspaceId(nil, client: client) + try resolveWorkspaceIdAllowingFallback(raw, client: client) } private func resolveSurfaceIdForClaudeHook( @@ -10496,12 +10530,125 @@ struct CMUXCLI { workspaceId: String, client: SocketClient ) throws -> String { - if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) { + try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client) + } + + private func resolveWorkspaceIdAllowingFallback( + _ raw: String?, + client: SocketClient + ) throws -> String { + if let raw, + !raw.isEmpty, + let candidate = try? resolveWorkspaceId(raw, client: client), + (try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil { return candidate } + if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client), + (try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil { + return callerWorkspaceId + } + return try resolveWorkspaceId(nil, client: client) + } + + private func resolveSurfaceIdAllowingFallback( + _ raw: String?, + workspaceId: String, + client: SocketClient + ) throws -> String { + if let raw, + !raw.isEmpty, + let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client), + let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { + let items = listed["surfaces"] as? [[String: Any]] ?? [] + if items.contains(where: { + ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate + }) { + return candidate + } + } + if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client), + let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { + let items = listed["surfaces"] as? [[String: Any]] ?? [] + if items.contains(where: { + ($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId + }) { + return callerSurfaceId + } + } return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) } + private struct CallerTerminalBinding { + let workspaceId: String + let surfaceId: String + } + + private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? { + resolveCallerTerminalBindingByTTY(client: client)?.workspaceId + } + + private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? { + guard let binding = resolveCallerTerminalBindingByTTY(client: client), + binding.workspaceId == workspaceId else { + return nil + } + return binding.surfaceId + } + + private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? { + guard let ttyName = resolveCallerTTYName() else { + return nil + } + guard let payload = try? client.sendV2(method: "debug.terminals") else { + return nil + } + let terminals = payload["terminals"] as? [[String: Any]] ?? [] + for terminal in terminals { + guard normalizedTTYName(terminal["tty"] as? String) == ttyName, + let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String), + let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else { + continue + } + return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) + } + return nil + } + + private func resolveCallerTTYName() -> String? { + let env = ProcessInfo.processInfo.environment + for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] { + if let ttyName = normalizedTTYName(env[key]) { + return ttyName + } + } + for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { + if let rawTTYName = ttyname(fileDescriptor), + let ttyName = normalizedTTYName(String(cString: rawTTYName)) { + return ttyName + } + } + return nil + } + + private func normalizedTTYName(_ raw: String?) -> String? { + guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else { + return nil + } + let components = trimmed.split(separator: "/") + if let last = components.last, !last.isEmpty { + return String(last) + } + return trimmed + } + + private func normalizedHandleValue(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { + return nil + } + return raw + } + private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput { let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15d51723..cba1b214 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -929,7 +929,9 @@ final class FileDropOverlayView: NSView { var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 +private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") +let tmuxWorkspacePaneOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.tmuxWorkspacePane.overlay.container") enum CommandPaletteOverlayPromotionPolicy { static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { @@ -950,6 +952,15 @@ private final class CommandPaletteOverlayContainerView: NSView { } } +@MainActor +private final class PassthroughWindowOverlayContainerView: NSView { + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + @MainActor private final class WindowCommandPaletteOverlayController: NSObject { private weak var window: NSWindow? @@ -1266,6 +1277,103 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } +@MainActor +private final class WindowTmuxWorkspacePaneOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = PassthroughWindowOverlayContainerView(frame: .zero) + private let model = TmuxWorkspacePaneOverlayModel() + private let hostingView: NSHostingView + private var installConstraints: [NSLayoutConstraint] = [] + + init(window: NSWindow) { + self.window = window + self.hostingView = NSHostingView( + rootView: TmuxWorkspacePaneOverlayView( + unreadRects: [], + flashRect: nil, + flashStartedAt: nil, + flashReason: nil + ) + ) + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.identifier = tmuxWorkspacePaneOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: contentView) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + } + + return true + } + + func update(state: TmuxWorkspacePaneOverlayRenderState?) { + guard ensureInstalled() else { return } + if let state { + model.apply(state) + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: model.unreadRects, + flashRect: model.flashRect, + flashStartedAt: model.flashStartedAt, + flashReason: model.flashReason + ) + containerView.alphaValue = 1 + containerView.isHidden = false + } else { + model.clear() + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: [], + flashRect: nil, + flashStartedAt: nil, + flashReason: nil + ) + containerView.alphaValue = 0 + containerView.isHidden = true + } + } +} + +@MainActor +private func tmuxWorkspacePaneWindowOverlayController(for window: NSWindow) -> WindowTmuxWorkspacePaneOverlayController { + if let existing = objc_getAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey) as? WindowTmuxWorkspacePaneOverlayController { + return existing + } + let controller = WindowTmuxWorkspacePaneOverlayController(window: window) + objc_setAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebView? { guard let responder else { return nil } @@ -1622,6 +1730,135 @@ struct ContentView: View { var lastUsedAt: TimeInterval } + static func tmuxWorkspacePaneExactRect( + for panel: Panel, + in contentView: NSView + ) -> CGRect? { + let targetView: NSView? + switch panel { + case let terminal as TerminalPanel: + targetView = terminal.hostedView + case let browser as BrowserPanel: + targetView = browser.webView + default: + targetView = nil + } + guard let targetView else { return nil } + return tmuxWorkspacePaneExactRect(for: targetView, in: contentView) + } + + static func tmuxWorkspacePaneExactRect( + for targetView: NSView, + in contentView: NSView + ) -> CGRect? { + guard let contentWindow = contentView.window, + let targetWindow = targetView.window, + contentWindow === targetWindow, + targetView.superview != nil else { + return nil + } + + let rectInWindow = targetView.convert(targetView.bounds, to: nil) + let rectInContent = contentView.convert(rectInWindow, from: nil) + guard rectInContent.width > 1, rectInContent.height > 1 else { return nil } + return rectInContent + } + + static func preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: CGRect?, + paneRect: CGRect? + ) -> CGRect? { + guard let paneRect else { return exactRect } + guard let exactRect, + exactRect.width > 1, + exactRect.height > 1 else { + return paneRect + } + + let tolerance: CGFloat = 0.5 + let exactFitsWithinPane = + exactRect.minX >= paneRect.minX - tolerance && + exactRect.maxX <= paneRect.maxX + tolerance && + exactRect.minY >= paneRect.minY - tolerance && + exactRect.maxY <= paneRect.maxY + tolerance + return exactFitsWithinPane ? exactRect : paneRect + } + + private func tmuxWorkspacePaneWindowOverlayState(for window: NSWindow) -> TmuxWorkspacePaneOverlayRenderState? { + guard TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay, + let workspace = tabManager.selectedWorkspace else { return nil } + let layoutSnapshot = WorkspaceContentView.effectiveTmuxLayoutSnapshot( + cachedSnapshot: workspace.tmuxLayoutSnapshot, + liveSnapshot: workspace.bonsplitController.layoutSnapshot() + ) + let contentView = window.contentView + + let unreadRects: [CGRect] + if let layoutSnapshot, let contentView { + unreadRects = layoutSnapshot.panes.compactMap { pane in + guard let selectedTabId = pane.selectedTabId, + let tabUUID = UUID(uuidString: selectedTabId), + let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)), + let panel = workspace.panels[panelId] else { + return nil + } + + let shouldShowUnread = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panelId + ), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + ) + guard shouldShowUnread else { return nil } + + let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.paneId(forPanelId: panelId) + ) + let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView) + return Self.preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: exactRect, + paneRect: paneRect + ) + } + } else { + unreadRects = WorkspaceContentView.tmuxWorkspacePaneWindowUnreadRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot + ) + } + + let flashRect: CGRect? + if let panelId = workspace.tmuxWorkspaceFlashPanelId, + let panel = workspace.panels[panelId], + let contentView { + let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.paneId(forPanelId: panelId) + ) + let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView) + flashRect = Self.preferredTmuxWorkspacePaneWindowOverlayRect( + exactRect: exactRect, + paneRect: paneRect + ) + } else { + flashRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: layoutSnapshot, + paneId: workspace.tmuxWorkspaceFlashPanelId.flatMap { workspace.paneId(forPanelId: $0) } + ) + } + + return TmuxWorkspacePaneOverlayRenderState( + workspaceId: workspace.id, + unreadRects: unreadRects, + flashRect: flashRect, + flashToken: workspace.tmuxWorkspaceFlashToken, + flashReason: workspace.tmuxWorkspaceFlashReason + ) + } + private struct CommandPaletteContextSnapshot { private var boolValues: [String: Bool] = [:] private var stringValues: [String: String] = [:] @@ -2762,6 +2999,8 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in MainActor.assumeIsolated { + let tmuxOverlayController = tmuxWorkspacePaneWindowOverlayController(for: window) + tmuxOverlayController.update(state: tmuxWorkspacePaneWindowOverlayState(for: window)) let overlayController = commandPaletteWindowOverlayController(for: window) overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4302bd21..5bd27e93 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6470,13 +6470,32 @@ func shouldAllowEnsureFocusWindowActivation( final class GhosttySurfaceScrollView: NSView { enum FlashStyle { - case standardFocus - case notificationDismiss + case navigation + case notification + } + + static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle { + switch reason { + case .navigation: + return .navigation + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + return .notification + } + } + + private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation { + switch style { + case .navigation: + return WorkspaceAttentionCoordinator.flashStyle(for: .navigation) + case .notification: + return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) + } } private enum NotificationRingMetrics { - static let inset: CGFloat = 2 - static let cornerRadius: CGFloat = 6 + static let inset = PanelOverlayRingMetrics.inset + static let cornerRadius = PanelOverlayRingMetrics.cornerRadius + static let lineWidth = PanelOverlayRingMetrics.lineWidth } private let backgroundView: NSView @@ -6489,6 +6508,7 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var lastFlashStyle: FlashStyle = .navigation private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView @@ -6743,7 +6763,7 @@ final class GhosttySurfaceScrollView: NSView { notificationRingOverlayView.autoresizingMask = [.width, .height] notificationRingLayer.fillColor = NSColor.clear.cgColor notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor - notificationRingLayer.lineWidth = 2.5 + notificationRingLayer.lineWidth = NotificationRingMetrics.lineWidth notificationRingLayer.lineJoin = .round notificationRingLayer.lineCap = .round notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor @@ -6759,13 +6779,13 @@ final class GhosttySurfaceScrollView: NSView { flashOverlayView.layer?.masksToBounds = false flashOverlayView.autoresizingMask = [.width, .height] flashLayer.fillColor = NSColor.clear.cgColor - flashLayer.strokeColor = NSColor.systemBlue.cgColor - flashLayer.lineWidth = 3 + flashLayer.strokeColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor + flashLayer.lineWidth = NotificationRingMetrics.lineWidth flashLayer.lineJoin = .round flashLayer.lineCap = .round - flashLayer.shadowColor = NSColor.systemBlue.cgColor - flashLayer.shadowOpacity = 0.6 - flashLayer.shadowRadius = 6 + flashLayer.shadowColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor + flashLayer.shadowOpacity = Float(WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowOpacity) + flashLayer.shadowRadius = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowRadius flashLayer.shadowOffset = .zero flashLayer.opacity = 0 flashOverlayView.layer?.addSublayer(flashLayer) @@ -7069,7 +7089,8 @@ final class GhosttySurfaceScrollView: NSView { // which makes interactive width changes arrive a queue turn late on Sequoia. scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() - updateFlashPath(style: .standardFocus) + updateFlashPath(style: lastFlashStyle) + updateFlashAppearance(style: lastFlashStyle) synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() @@ -7801,15 +7822,17 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func triggerFlash(style: FlashStyle = .standardFocus) { + func triggerFlash(style: FlashStyle = .navigation) { DispatchQueue.main.async { [weak self] in guard let self else { return } -#if DEBUG + self.lastFlashStyle = style + #if DEBUG if let surfaceId = self.surfaceView.terminalSurface?.id { Self.recordFlash(for: surfaceId) } #endif self.updateFlashPath(style: style) + self.updateFlashAppearance(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") @@ -8873,10 +8896,7 @@ final class GhosttySurfaceScrollView: NSView { let inset: CGFloat let radius: CGFloat switch style { - case .standardFocus: - inset = CGFloat(FocusFlashPattern.ringInset) - radius = CGFloat(FocusFlashPattern.ringCornerRadius) - case .notificationDismiss: + case .navigation, .notification: inset = NotificationRingMetrics.inset radius = NotificationRingMetrics.cornerRadius } @@ -8888,6 +8908,15 @@ final class GhosttySurfaceScrollView: NSView { ) } + private func updateFlashAppearance(style: FlashStyle) { + let presentation = Self.flashPresentation(for: style) + let strokeColor = presentation.accent.strokeColor + flashLayer.strokeColor = strokeColor.cgColor + flashLayer.shadowColor = strokeColor.cgColor + flashLayer.shadowOpacity = Float(presentation.glowOpacity) + flashLayer.shadowRadius = presentation.glowRadius + } + private func updateOverlayRingPath( layer: CAShapeLayer, bounds: CGRect, @@ -8899,7 +8928,7 @@ final class GhosttySurfaceScrollView: NSView { layer.path = nil return } - let rect = bounds.insetBy(dx: inset, dy: inset) + let rect = PanelOverlayRingMetrics.pathRect(in: bounds) layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index da61b37e..4049c30e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2858,7 +2858,8 @@ final class BrowserPanel: Panel, ObservableObject { return true } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 2e74944d..9608df6c 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -75,7 +75,8 @@ final class MarkdownPanel: Panel, ObservableObject { stopFileWatcher() } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken += 1 } diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index bcbf5b7d..5894be91 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -26,11 +26,134 @@ public enum PanelFocusIntent: Equatable { case browser(BrowserPanelFocusIntent) } +public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { + case navigation + case notificationArrival + case notificationDismiss + case manualUnreadDismiss + case debug +} + +enum WorkspaceAttentionFlashAccent: Equatable, Sendable { + case notificationBlue + case navigationNeutral + + var strokeColor: NSColor { + switch self { + case .notificationBlue: + return .systemBlue + case .navigationNeutral: + return .systemGray + } + } +} + +struct WorkspaceAttentionFlashPresentation: Equatable, Sendable { + let accent: WorkspaceAttentionFlashAccent + let glowOpacity: Double + let glowRadius: CGFloat +} + +struct WorkspaceAttentionPersistentState: Equatable, Sendable { + var unreadPanelIDs: Set = [] + var focusedReadPanelID: UUID? + var manualUnreadPanelIDs: Set = [] + + var indicatorPanelIDs: Set { + var ids = unreadPanelIDs.union(manualUnreadPanelIDs) + if let focusedReadPanelID { + ids.insert(focusedReadPanelID) + } + return ids + } + + func hasCompetingIndicator(for panelID: UUID) -> Bool { + indicatorPanelIDs.contains(where: { $0 != panelID }) + } +} + +struct WorkspaceAttentionFlashDecision: Equatable, Sendable { + let panelID: UUID + let reason: WorkspaceAttentionFlashReason + let isAllowed: Bool +} + +enum WorkspaceAttentionCoordinator { + static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> WorkspaceAttentionFlashPresentation { + switch reason { + case .navigation: + return WorkspaceAttentionFlashPresentation( + accent: .navigationNeutral, + glowOpacity: 0.14, + glowRadius: 3 + ) + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + return WorkspaceAttentionFlashPresentation( + accent: .notificationBlue, + glowOpacity: 0.6, + glowRadius: 6 + ) + } + } + + static func decideFlash( + targetPanelID: UUID, + reason: WorkspaceAttentionFlashReason, + persistentState: WorkspaceAttentionPersistentState + ) -> WorkspaceAttentionFlashDecision { + let isAllowed: Bool + switch reason { + case .navigation: + isAllowed = !persistentState.hasCompetingIndicator(for: targetPanelID) + case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: + isAllowed = true + } + + return WorkspaceAttentionFlashDecision( + panelID: targetPanelID, + reason: reason, + isAllowed: isAllowed + ) + } +} + enum FocusFlashCurve: Equatable { case easeIn case easeOut } +enum PanelOverlayRingMetrics { + static let inset: CGFloat = 2 + static let cornerRadius: CGFloat = 6 + static let lineWidth: CGFloat = 2.5 + + static func pathRect(in bounds: CGRect) -> CGRect { + bounds.insetBy(dx: inset, dy: inset) + } +} + +#if DEBUG +func cmuxFlashDebugID(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(6)) +} + +func cmuxFlashDebugRect(_ rect: CGRect?) -> String { + guard let rect else { return "nil" } + return String( + format: "%.1f,%.1f %.1fx%.1f", + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height + ) +} + +func cmuxFlashDebugBool(_ value: Bool) -> Int { + value ? 1 : 0 +} +#endif + struct FocusFlashSegment: Equatable { let delay: TimeInterval let duration: TimeInterval @@ -43,8 +166,8 @@ enum FocusFlashPattern { static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] static let duration: TimeInterval = 0.9 static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] - static let ringInset: Double = 6 - static let ringCornerRadius: Double = 10 + static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) + static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) static var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) @@ -59,6 +182,37 @@ enum FocusFlashPattern { ) } } + + static func opacity(at elapsed: TimeInterval) -> Double { + guard elapsed >= 0, elapsed <= duration else { return 0 } + + for index in 0.. endTime { + continue + } + + let segmentDuration = max(endTime - startTime, 0.0001) + let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration)) + let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index]) + let startOpacity = values[index] + let endOpacity = values[index + 1] + return startOpacity + ((endOpacity - startOpacity) * curvedProgress) + } + + return values.last ?? 0 + } + + private static func interpolatedProgress(_ progress: Double, curve: FocusFlashCurve) -> Double { + switch curve { + case .easeIn: + return progress * progress + case .easeOut: + let inverse = 1 - progress + return 1 - (inverse * inverse) + } + } } /// Protocol for all panel types (terminal, browser, etc.) @@ -89,7 +243,7 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI func unfocus() /// Trigger a focus flash animation for this panel. - func triggerFlash() + func triggerFlash(reason: WorkspaceAttentionFlashReason) /// Capture the panel-local focus target that should be restored later. func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent @@ -149,4 +303,8 @@ extension Panel { _ = window return false } + + func triggerFlash() { + triggerFlash(reason: .navigation) + } } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 3df74b9c..7bc65d51 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -22,6 +22,8 @@ final class TerminalPanel: Panel, ObservableObject { /// Published directory from the terminal @Published private(set) var directory: String = "" + @Published private(set) var tmuxLayoutReport: TmuxPaneLayoutReport? + /// Search state for find functionality @Published var searchState: TerminalSurface.SearchState? { didSet { @@ -36,6 +38,8 @@ final class TerminalPanel: Panel, ObservableObject { /// (hostedView.window == nil) until the user switches workspaces. @Published var viewReattachToken: UInt64 = 0 + var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? + private var cancellables = Set() var displayTitle: String { @@ -125,6 +129,11 @@ final class TerminalPanel: Panel, ObservableObject { surface.updateWorkspaceId(newWorkspaceId) } + func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) { + guard tmuxLayoutReport != report else { return } + tmuxLayoutReport = report + } + func focus() { surface.setFocus(true) // `unfocus()` force-disables active state to stop stale retries from stealing focus. @@ -200,14 +209,23 @@ final class TerminalPanel: Panel, ObservableObject { !surface.needsConfirmClose() } - func triggerFlash() { + func triggerFlash(reason: WorkspaceAttentionFlashReason) { guard NotificationPaneFlashSettings.isEnabled() else { return } - hostedView.triggerFlash() + + switch TmuxOverlayExperimentSettings.target() { + case .bonsplitPane: + if let onRequestWorkspacePaneFlash { + onRequestWorkspacePaneFlash(reason) + return + } + hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason)) + case .surface, .tmuxActivePane: + hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason)) + } } func triggerNotificationDismissFlash() { - guard NotificationPaneFlashSettings.isEnabled() else { return } - hostedView.triggerFlash(style: .notificationDismiss) + triggerFlash(reason: .notificationDismiss) } func applyWindowBackgroundIfActive() { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf..02113470 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2760,6 +2760,7 @@ class TabManager: ObservableObject { if let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } + notificationStore.setFocusedReadIndicator(forTabId: tabId, surfaceId: panelId) notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } @@ -2768,12 +2769,17 @@ class TabManager: ObservableObject { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) + let hasFocusedIndicator = notificationStore.hasVisibleNotificationIndicator(forTabId: tabId, surfaceId: surfaceId) + guard hasUnreadNotification || hasFocusedIndicator else { return false } + if hasUnreadNotification { + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + } + notificationStore.clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + tab.triggerNotificationDismissFlash(panelId: panelId) } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) return true } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 84b7a5a1..d75ca8ed 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -697,6 +697,7 @@ final class TerminalNotificationStore: ObservableObject { refreshDockBadge() } } + @Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:] @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private let center = UNUserNotificationCenter.current() @@ -876,10 +877,19 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)) } + func hasVisibleNotificationIndicator(forTabId tabId: UUID, surfaceId: UUID?) -> Bool { + hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) || + focusedReadIndicatorByTabId[tabId] == surfaceId + } + func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } + func focusedReadIndicatorSurfaceId(forTabId tabId: UUID) -> UUID? { + focusedReadIndicatorByTabId[tabId] + } + func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) { var updated = notifications var idsToClear: [String] = [] @@ -889,12 +899,20 @@ final class TerminalNotificationStore: ObservableObject { return true } + if let existingIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId], + existingIndicatorSurfaceId != surfaceId { + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId) let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel + if shouldSuppressExternalDelivery { + setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) + } if WorkspaceAutoReorderSettings.isEnabled() { AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) @@ -979,6 +997,24 @@ final class TerminalNotificationStore: ObservableObject { } } + func setFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID?) { + guard let surfaceId else { return } + guard focusedReadIndicatorByTabId[tabId] != surfaceId else { return } + focusedReadIndicatorByTabId[tabId] = surfaceId + } + + func clearFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID? = nil) { + guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return } + guard surfaceId == nil || existingSurfaceId == surfaceId else { return } + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + + func clearFocusedReadIndicatorIfSurfaceChanged(forTabId tabId: UUID, surfaceId: UUID?) { + guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return } + guard existingSurfaceId != surfaceId else { return } + focusedReadIndicatorByTabId.removeValue(forKey: tabId) + } + func markAllRead() { var updated = notifications var idsToClear: [String] = [] @@ -997,17 +1033,22 @@ final class TerminalNotificationStore: ObservableObject { func remove(id: UUID) { var updated = notifications + let removed = updated.first(where: { $0.id == id }) let originalCount = updated.count updated.removeAll { $0.id == id } guard updated.count != originalCount else { return } notifications = updated + if let removed { + clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId) + } center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func clearAll() { - guard !notifications.isEmpty else { return } + guard !notifications.isEmpty || !focusedReadIndicatorByTabId.isEmpty else { return } let ids = notifications.map { $0.id.uuidString } notifications.removeAll() + focusedReadIndicatorByTabId.removeAll() center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) } @@ -1025,6 +1066,7 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated + clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1042,6 +1084,7 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated + clearFocusedReadIndicator(forTabId: tabId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } @@ -1334,6 +1377,7 @@ final class TerminalNotificationStore: ObservableObject { func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { self.notifications = notifications + focusedReadIndicatorByTabId.removeAll() } #endif diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index da7df21c..b7c9754a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5201,6 +5201,10 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] + @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? + @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? + @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? + @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @@ -5437,6 +5441,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: initialTerminalCommand, initialEnvironmentOverrides: initialTerminalEnvironment ) + configureTerminalPanel(terminalPanel) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) @@ -5487,6 +5492,7 @@ final class Workspace: Identifiable, ObservableObject { } bonsplitController.selectTab(initialTabId) } + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } deinit { @@ -5607,6 +5613,19 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId.first { $0.value == panelId }?.key } + private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in + guard let self, let terminalPanel else { return } + self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) + } + } + + private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + tmuxWorkspaceFlashPanelId = panelId + tmuxWorkspaceFlashReason = reason + tmuxWorkspaceFlashToken &+= 1 + } + private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { let subscription = Publishers.CombineLatest3( @@ -5768,7 +5787,31 @@ final class Workspace: Identifiable, ObservableObject { } private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false + AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false + } + + private func attentionPersistentState() -> WorkspaceAttentionPersistentState { + let notificationStore = AppDelegate.shared?.notificationStore + let unreadPanelIDs = Set( + panels.keys.filter { + notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false + } + ) + return WorkspaceAttentionPersistentState( + unreadPanelIDs: unreadPanelIDs, + focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), + manualUnreadPanelIDs: manualUnreadPanelIds + ) + } + + private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + let decision = WorkspaceAttentionCoordinator.decideFlash( + targetPanelID: panelId, + reason: reason, + persistentState: attentionPersistentState() + ) + guard decision.isAllowed else { return } + panels[panelId]?.triggerFlash(reason: reason) } private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { @@ -6983,6 +7026,7 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -7072,6 +7116,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -8184,8 +8229,10 @@ final class Workspace: Identifiable, ObservableObject { } func moveFocus(direction: NavigationDirection) { + let previousFocusedPanelId = focusedPanelId + // Unfocus the currently-focused panel before navigating. - if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] { + if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { prev.unfocus() } @@ -8197,6 +8244,10 @@ final class Workspace: Identifiable, ObservableObject { let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } + + if let focusedPanelId, focusedPanelId != previousFocusedPanelId { + triggerFocusFlash(panelId: focusedPanelId) + } } // MARK: - Surface Navigation @@ -8301,7 +8352,7 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - panels[panelId]?.triggerFlash() + requestAttentionFlash(panelId: panelId, reason: .navigation) } func triggerNotificationFocusFlash( @@ -8309,7 +8360,7 @@ final class Workspace: Identifiable, ObservableObject { requiresSplit: Bool = false, shouldFocus: Bool = true ) { - guard let terminalPanel = terminalPanel(for: panelId) else { return } + guard terminalPanel(for: panelId) != nil else { return } if shouldFocus { focusPanel(panelId) } @@ -8317,11 +8368,18 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + } + + func triggerNotificationDismissFlash(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) } func triggerDebugFlash(panelId: UUID) { - triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true) + guard panels[panelId] != nil else { return } + focusPanel(panelId) + requestAttentionFlash(panelId: panelId, reason: .debug) } // MARK: - Portal Lifecycle @@ -8359,6 +8417,7 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10040,6 +10099,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) @@ -10106,6 +10166,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10200,7 +10261,7 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - _ = snapshot + tmuxLayoutSnapshot = snapshot scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index a74efffa..b5c66519 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -3,6 +3,217 @@ import Foundation import AppKit import Bonsplit +enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable { + case surface + case bonsplitPane + case tmuxActivePane + + var usesWorkspacePaneOverlay: Bool { + self == .bonsplitPane + } + + var usesTmuxActivePaneOverlay: Bool { + self == .tmuxActivePane + } +} + +struct TmuxOverlayExperimentSettings { + static let enabledKey = "tmuxOverlayExperimentEnabled" + static let targetKey = "tmuxOverlayExperimentTarget" + static let defaultEnabled = false + static let defaultTarget: TmuxOverlayExperimentTarget = .surface + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } + + static func target(defaults: UserDefaults = .standard) -> TmuxOverlayExperimentTarget { + target( + enabled: isEnabled(defaults: defaults), + rawValue: defaults.string(forKey: targetKey) + ) + } + + static func target(enabled: Bool, rawValue: String?) -> TmuxOverlayExperimentTarget { + guard enabled else { return .surface } + guard let rawValue, + let target = TmuxOverlayExperimentTarget(rawValue: rawValue) else { + return defaultTarget + } + return target + } +} + +struct TmuxPaneLayoutPane: Codable, Equatable, Sendable { + let paneId: String + let left: Int + let top: Int + let width: Int + let height: Int + let isActive: Bool +} + +struct TmuxPaneLayoutReport: Codable, Equatable, Sendable { + let panes: [TmuxPaneLayoutPane] + + var activePane: TmuxPaneLayoutPane? { + panes.first(where: \.isActive) ?? panes.first + } +} + +func tmuxActivePaneOverlayRect( + surfaceFrame: CGRect, + cellSize: CGSize, + pane: TmuxPaneLayoutPane +) -> CGRect? { + guard cellSize.width > 0, + cellSize.height > 0, + pane.width > 0, + pane.height > 0 else { + return nil + } + + return CGRect( + x: surfaceFrame.origin.x + (CGFloat(pane.left) * cellSize.width), + y: surfaceFrame.origin.y + (CGFloat(pane.top) * cellSize.height), + width: CGFloat(pane.width) * cellSize.width, + height: CGFloat(pane.height) * cellSize.height + ) +} + +private extension PixelRect { + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct TmuxWorkspacePaneOverlayRenderState: Equatable { + let workspaceId: UUID + let unreadRects: [CGRect] + let flashRect: CGRect? + let flashToken: UInt64 + let flashReason: WorkspaceAttentionFlashReason? +} + +@MainActor +final class TmuxWorkspacePaneOverlayModel: ObservableObject { + @Published private(set) var unreadRects: [CGRect] = [] + @Published private(set) var flashRect: CGRect? + @Published private(set) var flashStartedAt: Date? + @Published private(set) var flashReason: WorkspaceAttentionFlashReason? + + private var lastWorkspaceId: UUID? + private var lastFlashToken: UInt64? + + func apply( + _ state: TmuxWorkspacePaneOverlayRenderState, + now: () -> Date = Date.init + ) { + unreadRects = state.unreadRects + flashRect = state.flashRect + flashReason = state.flashReason + + let didChangeWorkspace = lastWorkspaceId != state.workspaceId + if didChangeWorkspace { + lastWorkspaceId = state.workspaceId + lastFlashToken = state.flashToken + flashStartedAt = nil + return + } + + if let lastFlashToken, + state.flashToken != lastFlashToken, + state.flashRect != nil { + flashStartedAt = now() + } + self.lastFlashToken = state.flashToken + } + + func clear() { + unreadRects = [] + flashRect = nil + flashStartedAt = nil + flashReason = nil + lastWorkspaceId = nil + lastFlashToken = nil + } +} + +struct TmuxWorkspacePaneOverlayView: View { + let unreadRects: [CGRect] + let flashRect: CGRect? + let flashStartedAt: Date? + let flashReason: WorkspaceAttentionFlashReason? + + var body: some View { + TimelineView(.animation) { timeline in + Canvas { context, _ in + for rect in unreadRects { + drawUnreadRing(in: &context, rect: rect) + } + + guard let flashRect, + let flashStartedAt else { return } + let elapsed = timeline.date.timeIntervalSince(flashStartedAt) + let opacity = FocusFlashPattern.opacity(at: elapsed) + guard opacity > 0.001 else { return } + drawFlashRing( + in: &context, + rect: flashRect, + opacity: opacity, + reason: flashReason ?? .notificationArrival + ) + } + } + .allowsHitTesting(false) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) { + guard let path = ringPath(for: rect) else { return } + var glowContext = context + glowContext.addFilter(.shadow(color: Color.blue.opacity(0.35), radius: 3)) + glowContext.stroke( + path, + with: .color(Color.blue), + style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round) + ) + } + + private func drawFlashRing( + in context: inout GraphicsContext, + rect: CGRect, + opacity: Double, + reason: WorkspaceAttentionFlashReason + ) { + guard let path = ringPath(for: rect) else { return } + let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason) + let strokeColor = Color(nsColor: presentation.accent.strokeColor) + + var glowContext = context + glowContext.addFilter( + .shadow( + color: strokeColor.opacity(opacity * presentation.glowOpacity), + radius: presentation.glowRadius + ) + ) + glowContext.stroke( + path, + with: .color(strokeColor.opacity(opacity)), + style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round) + ) + } + + private func ringPath(for rect: CGRect) -> Path? { + guard rect.width > PanelOverlayRingMetrics.inset * 2, + rect.height > PanelOverlayRingMetrics.inset * 2 else { return nil } + return Path( + roundedRect: PanelOverlayRingMetrics.pathRect(in: rect), + cornerRadius: PanelOverlayRingMetrics.cornerRadius + ) + } +} + /// View that renders a Workspace's content using BonsplitView struct WorkspaceContentView: View { @ObservedObject var workspace: Workspace @@ -40,6 +251,7 @@ struct WorkspaceContentView: View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || workspace.panels.count > 1 + let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay // Inactive workspaces are kept alive in a ZStack (for state preservation) but their // AppKit-backed views can still intercept drags. Disable drop acceptance for them. @@ -69,8 +281,11 @@ struct WorkspaceContentView: View { isSelectedInPane: isSelectedInPane, isFocused: isFocused ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + let showsNotificationRing = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panel.id + ), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) ) PanelContentView( @@ -82,7 +297,7 @@ struct WorkspaceContentView: View { portalPriority: workspacePortalPriority, isSplit: isSplit, appearance: appearance, - hasUnreadNotification: hasUnreadNotification, + hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay, onFocus: { // Keep bonsplit focus in sync with the AppKit first responder for the // active workspace. This prevents divergence between the blue focused-tab @@ -165,11 +380,6 @@ struct WorkspaceContentView: View { } private func syncBonsplitNotificationBadges() { - let unreadFromNotifications: Set = Set( - notificationStore.notifications - .filter { $0.tabId == workspace.id && !$0.isRead } - .compactMap { $0.surfaceId } - ) let manualUnread = workspace.manualUnreadPanelIds for paneId in workspace.bonsplitController.allPaneIds { @@ -177,7 +387,10 @@ struct WorkspaceContentView: View { let panelId = workspace.panelIdFromSurfaceId(tab.id) let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false - let shouldShow = panelId.map { unreadFromNotifications.contains($0) || manualUnread.contains($0) } ?? false + let shouldShow = panelId.map { + notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) || + manualUnread.contains($0) + } ?? false let kindUpdate: String?? = expectedKind.map { .some($0) } if tab.showsNotificationBadge != shouldShow || @@ -198,6 +411,176 @@ struct WorkspaceContentView: View { workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" } + private static let tmuxWorkspacePaneTopChromeHeight: CGFloat = 30 + + private enum TmuxWorkspacePaneOverlayTrimMode { + case workspaceLocal + case windowContent + } + + private static func tmuxWorkspacePaneContentRect( + _ rect: CGRect, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> CGRect { + let topInset = min(tmuxWorkspacePaneTopChromeHeight, max(0, rect.height - 1)) + switch trimMode { + case .workspaceLocal, .windowContent: + return CGRect( + x: rect.origin.x, + y: rect.origin.y + topInset, + width: rect.width, + height: max(0, rect.height - topInset) + ) + } + } + + private static func tmuxWorkspacePaneRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID?, + includeContainerOffset: Bool, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> CGRect? { + guard let layoutSnapshot, + let paneId, + let paneRect = layoutSnapshot.panes + .first(where: { $0.paneId == paneId.id.uuidString })? + .frame + .cgRect else { + return nil + } + + let rect: CGRect + if includeContainerOffset { + rect = paneRect.offsetBy( + dx: 0, + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } else { + rect = paneRect.offsetBy( + dx: -CGFloat(layoutSnapshot.containerFrame.x), + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } + return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode) + } + + private static func tmuxWorkspacePaneRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot?, + includeContainerOffset: Bool, + trimMode: TmuxWorkspacePaneOverlayTrimMode + ) -> [CGRect] { + guard let layoutSnapshot else { return [] } + + return layoutSnapshot.panes.compactMap { pane in + guard let selectedTabId = pane.selectedTabId, + let tabUUID = UUID(uuidString: selectedTabId), + let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)) else { + return nil + } + + let shouldShowUnread = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( + forTabId: workspace.id, + surfaceId: panelId + ), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) + ) + guard shouldShowUnread else { return nil } + + let paneRect = pane.frame.cgRect + let rect: CGRect + if includeContainerOffset { + rect = paneRect.offsetBy( + dx: 0, + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } else { + rect = paneRect.offsetBy( + dx: -CGFloat(layoutSnapshot.containerFrame.x), + dy: -CGFloat(layoutSnapshot.containerFrame.y) + ) + } + return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode) + } + } + + static func tmuxWorkspacePaneOverlayRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID? + ) -> CGRect? { + tmuxWorkspacePaneRect( + layoutSnapshot: layoutSnapshot, + paneId: paneId, + includeContainerOffset: false, + trimMode: .workspaceLocal + ) + } + + static func tmuxWorkspacePaneWindowOverlayRect( + layoutSnapshot: LayoutSnapshot?, + paneId: PaneID? + ) -> CGRect? { + tmuxWorkspacePaneRect( + layoutSnapshot: layoutSnapshot, + paneId: paneId, + includeContainerOffset: true, + trimMode: .windowContent + ) + } + + static func effectiveTmuxLayoutSnapshot( + cachedSnapshot: LayoutSnapshot?, + liveSnapshot: LayoutSnapshot? + ) -> LayoutSnapshot? { + if let liveSnapshot, + tmuxLayoutSnapshotHasRenderableGeometry(liveSnapshot) { + return liveSnapshot + } + if let cachedSnapshot, + tmuxLayoutSnapshotHasRenderableGeometry(cachedSnapshot) { + return cachedSnapshot + } + return cachedSnapshot ?? liveSnapshot + } + + static func tmuxWorkspacePaneUnreadRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot? + ) -> [CGRect] { + tmuxWorkspacePaneRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot, + includeContainerOffset: false, + trimMode: .workspaceLocal + ) + } + + static func tmuxWorkspacePaneWindowUnreadRects( + workspace: Workspace, + notificationStore: TerminalNotificationStore, + layoutSnapshot: LayoutSnapshot? + ) -> [CGRect] { + tmuxWorkspacePaneRects( + workspace: workspace, + notificationStore: notificationStore, + layoutSnapshot: layoutSnapshot, + includeContainerOffset: true, + trimMode: .windowContent + ) + } + + private static func tmuxLayoutSnapshotHasRenderableGeometry(_ snapshot: LayoutSnapshot) -> Bool { + snapshot.containerFrame.width > 1 && + snapshot.containerFrame.height > 1 && + snapshot.panes.contains { pane in + pane.frame.width > 1 && pane.frame.height > 1 + } + } + static func resolveGhosttyAppearanceConfig( reason: String = "unspecified", backgroundOverride: NSColor? = nil,