Fix tmux notification attention routing

This commit is contained in:
Lawrence Chen 2026-03-20 20:20:54 -07:00
parent d4811650d7
commit 656786fb71
No known key found for this signature in database
11 changed files with 1151 additions and 64 deletions

View file

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

View file

@ -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<TmuxWorkspacePaneOverlayView>
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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -75,7 +75,8 @@ final class MarkdownPanel: Panel, ObservableObject {
stopFileWatcher()
}
func triggerFlash() {
func triggerFlash(reason: WorkspaceAttentionFlashReason) {
_ = reason
guard NotificationPaneFlashSettings.isEnabled() else { return }
focusFlashToken += 1
}

View file

@ -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<UUID> = []
var focusedReadPanelID: UUID?
var manualUnreadPanelIDs: Set<UUID> = []
var indicatorPanelIDs: Set<UUID> {
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..<segments.count {
let startTime = keyTimes[index] * duration
let endTime = keyTimes[index + 1] * duration
if elapsed > 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)
}
}

View file

@ -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<AnyCancellable>()
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() {

View file

@ -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
}

View file

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

View file

@ -5201,6 +5201,10 @@ final class Workspace: Identifiable, ObservableObject {
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
@Published private(set) var pinnedPanelIds: Set<UUID> = []
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
@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()

View file

@ -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<UUID> = 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,