diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e1fd238b..63a9e148 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -33,12 +33,26 @@ class Tab: Identifiable, ObservableObject { return nil } - func focusSurface(_ id: UUID) { - guard focusedSurfaceId != id else { return } + func focusSurface(_ id: UUID, shouldFlash: Bool = true) { + let wasFocused = focusedSurfaceId == id focusedSurfaceId = id - if let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, selectedId == self.id { + let isSelectedTab = AppDelegate.shared?.tabManager?.selectedTabId == self.id + if isSelectedTab { focusedSurface?.applyWindowBackgroundIfActive() } + let isAppFocused = AppFocusState.isAppFocused() + guard isSelectedTab && isAppFocused else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) { + if shouldFlash { + triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false) + } + notificationStore.markRead(forTabId: self.id, surfaceId: id) + return + } + if !wasFocused { + notificationStore.markRead(forTabId: self.id, surfaceId: id) + } } func updateSurfaceDirectory(surfaceId: UUID, directory: String) { @@ -50,8 +64,8 @@ class Tab: Identifiable, ObservableObject { currentDirectory = trimmed } - func triggerNotificationFocusFlash(surfaceId: UUID) { - triggerPanelFlash(surfaceId: surfaceId, requiresSplit: true) + func triggerNotificationFocusFlash(surfaceId: UUID, requiresSplit: Bool = false) { + triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit) } func triggerDebugFlash(surfaceId: UUID) { @@ -64,7 +78,24 @@ class Tab: Identifiable, ObservableObject { if requiresSplit && !splitTree.isSplit { return } - surface.hostedView.triggerFlash() + triggerFlashWhenReady(surface: surface) + } + + private func triggerFlashWhenReady(surface: TerminalSurface, attempts: Int = 0) { + let maxAttempts = 6 + let view = surface.hostedView + if view.window != nil { + view.layoutSubtreeIfNeeded() + } + let hasBounds = view.bounds.width > 0 && view.bounds.height > 0 + if view.window != nil && hasBounds { + view.triggerFlash() + return + } + guard attempts < maxAttempts else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.triggerFlashWhenReady(surface: surface, attempts: attempts + 1) + } } func updateSplitViewSize(_ size: CGSize) { @@ -221,10 +252,14 @@ class TabManager: ObservableObject { DispatchQueue.main.async { [weak self] in self?.focusSelectedTabSurface(previousTabId: previousTabId) self?.updateWindowTitleForSelectedTab() + if let selectedTabId = self?.selectedTabId { + self?.markFocusedPanelReadIfActive(tabId: selectedTabId) + } } } } private var observers: [NSObjectProtocol] = [] + private var suppressFocusFlash = false init() { addTab() @@ -398,6 +433,20 @@ class TabManager: ObservableObject { surface.hostedView.ensureFocus(for: selectedTabId, surfaceId: surface.id) } + private func markFocusedPanelReadIfActive(tabId: UUID) { + let shouldSuppressFlash = suppressFocusFlash + suppressFocusFlash = false + guard AppFocusState.isAppFocused() else { return } + guard let surfaceId = focusedSurfaceId(for: tabId) else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + if !shouldSuppressFlash, + let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + } + private func updateTabTitle(tabId: UUID, title: String) { guard !title.isEmpty else { return } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -434,7 +483,7 @@ class TabManager: ObservableObject { return trimmedDirectory.isEmpty ? "cmuxterm" : trimmedDirectory } - func focusTab(_ tabId: UUID, surfaceId: UUID? = nil) { + func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { guard tabs.contains(where: { $0.id == tabId }) else { return } selectedTabId = tabId NotificationCenter.default.post( @@ -452,27 +501,33 @@ class TabManager: ObservableObject { } if let surfaceId { - focusSurface(tabId: tabId, surfaceId: surfaceId) + focusSurface(tabId: tabId, surfaceId: surfaceId, shouldFlash: !suppressFlash) } } func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) { + let wasSelected = selectedTabId == tabId + suppressFocusFlash = true focusTab(tabId, surfaceId: surfaceId) + if wasSelected { + suppressFocusFlash = false + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self, - let tab = self.tabs.first(where: { $0.id == tabId }), - tab.splitTree.isSplit else { return } + let tab = self.tabs.first(where: { $0.id == tabId }) else { return } let targetSurfaceId = surfaceId ?? tab.focusedSurfaceId guard let targetSurfaceId, tab.surface(for: targetSurfaceId) != nil else { return } - tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId) + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetSurfaceId) else { return } + tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false) } } - func focusSurface(tabId: UUID, surfaceId: UUID) { + func focusSurface(tabId: UUID, surfaceId: UUID, shouldFlash: Bool = true) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - tab.focusSurface(surfaceId) + tab.focusSurface(surfaceId, shouldFlash: shouldFlash) } func selectNextTab() { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b061d479..96c878a3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation /// Unix socket-based controller for programmatic terminal control @@ -102,20 +103,28 @@ class TerminalController { defer { close(socket) } var buffer = [UInt8](repeating: 0, count: 4096) + var pending = "" while isRunning { let bytesRead = read(socket, &buffer, buffer.count - 1) guard bytesRead > 0 else { break } - buffer[bytesRead] = 0 - let command = String(cString: buffer) - let response = processCommand(command.trimmingCharacters(in: .whitespacesAndNewlines)) + let chunk = String(bytes: buffer[0.. - Send special key (ctrl-c, ctrl-d, enter, tab, escape) send_surface - Send text to a surface in current tab send_key_surface - Send special key to a surface in current tab + notify |<body> - Create a notification for the focused surface + notify_surface <id|idx> <title>|<body> - Create a notification for a surface + list_notifications - List all notifications + clear_notifications - Clear all notifications + set_app_focus <active|inactive|clear> - Override app focus state + simulate_app_active - Trigger app active handler help - Show this help """ } @@ -289,6 +322,104 @@ class TerminalController { return success ? "OK" : "ERROR: Surface not found" } + private func notifyCurrent(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + var result = "OK" + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId else { + result = "ERROR: No tab selected" + return + } + let surfaceId = tabManager.focusedSurfaceId(for: tabId) + let (title, body) = parseNotificationPayload(args) + TerminalNotificationStore.shared.addNotification( + tabId: tabId, + surfaceId: surfaceId, + title: title, + body: body + ) + } + return result + } + + private func notifySurface(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" } + + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let surfaceArg = parts[0] + let payload = parts.count > 1 ? parts[1] : "" + + var result = "OK" + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + result = "ERROR: No tab selected" + return + } + guard let surfaceId = resolveSurfaceId(from: surfaceArg, tab: tab) else { + result = "ERROR: Surface not found" + return + } + let (title, body) = parseNotificationPayload(payload) + TerminalNotificationStore.shared.addNotification( + tabId: tabId, + surfaceId: surfaceId, + title: title, + body: body + ) + } + return result + } + + private func listNotifications() -> String { + var result = "" + DispatchQueue.main.sync { + let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in + let surfaceText = notification.surfaceId?.uuidString ?? "none" + let readText = notification.isRead ? "read" : "unread" + return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.body)" + } + result = lines.joined(separator: "\n") + } + return result.isEmpty ? "No notifications" : result + } + + private func clearNotifications() -> String { + DispatchQueue.main.sync { + TerminalNotificationStore.shared.clearAll() + } + return "OK" + } + + private func setAppFocusOverride(_ arg: String) -> String { + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch trimmed { + case "active", "1", "true": + AppFocusState.overrideIsFocused = true + return "OK" + case "inactive", "0", "false": + AppFocusState.overrideIsFocused = false + return "OK" + case "clear", "none", "": + AppFocusState.overrideIsFocused = nil + return "OK" + default: + return "ERROR: Expected active, inactive, or clear" + } + } + + private func simulateAppDidBecomeActive() -> String { + DispatchQueue.main.sync { + AppDelegate.shared?.applicationDidBecomeActive( + Notification(name: NSApplication.didBecomeActiveNotification) + ) + } + return "OK" + } + private func parseSplitDirection(_ value: String) -> SplitTree<TerminalSurface>.NewDirection? { switch value.lowercased() { case "left", "l": @@ -342,6 +473,29 @@ class TerminalController { return nil } + private func resolveSurfaceId(from arg: String, tab: Tab) -> UUID? { + if let uuid = UUID(uuidString: arg), tab.surface(for: uuid) != nil { + return uuid + } + + if let index = Int(arg), index >= 0 { + let surfaces = tab.splitTree.root?.leaves() ?? [] + guard index < surfaces.count else { return nil } + return surfaces[index].id + } + + return nil + } + + private func parseNotificationPayload(_ args: String) -> (String, String) { + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return ("Notification", "") } + let parts = trimmed.split(separator: "|", maxSplits: 1).map(String.init) + let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" + return (title.isEmpty ? "Notification" : title, body) + } + private func closeTab(_ tabId: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index cbc43e0f..8620b6db 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -2,6 +2,17 @@ import AppKit import Foundation import UserNotifications +enum AppFocusState { + static var overrideIsFocused: Bool? + + static func isAppFocused() -> Bool { + if let overrideIsFocused { + return overrideIsFocused + } + return NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) + } +} + struct TerminalNotification: Identifiable, Hashable { let id: UUID let tabId: UUID @@ -34,6 +45,10 @@ final class TerminalNotificationStore: ObservableObject { notifications.filter { $0.tabId == tabId && !$0.isRead }.count } + func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool { + notifications.contains { $0.tabId == tabId && $0.surfaceId == surfaceId && !$0.isRead } + } + func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { if let unread = notifications.first(where: { $0.tabId == tabId && !$0.isRead }) { return unread @@ -42,10 +57,17 @@ final class TerminalNotificationStore: ObservableObject { } func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) { + clearNotifications(forTabId: tabId, surfaceId: surfaceId) + let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId) let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId - let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab && isFocusedSurface + let isFocusedPanel = isActiveTab && isFocusedSurface + let isAppFocused = AppFocusState.isAppFocused() + if isAppFocused && isFocusedPanel { + return + } + let notification = TerminalNotification( id: UUID(), tabId: tabId, @@ -53,7 +75,7 @@ final class TerminalNotificationStore: ObservableObject { title: title, body: body, createdAt: Date(), - isRead: shouldMarkRead + isRead: false ) notifications.insert(notification, at: 0) scheduleUserNotification(notification) @@ -79,6 +101,22 @@ final class TerminalNotificationStore: ObservableObject { } } + func markRead(forTabId tabId: UUID, surfaceId: UUID?) { + var idsToClear: [String] = [] + for index in notifications.indices { + if notifications[index].tabId == tabId, + notifications[index].surfaceId == surfaceId, + !notifications[index].isRead { + notifications[index].isRead = true + idsToClear.append(notifications[index].id.uuidString) + } + } + if !idsToClear.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: idsToClear) + center.removePendingNotificationRequests(withIdentifiers: idsToClear) + } + } + func markUnread(forTabId tabId: UUID) { for index in notifications.indices { if notifications[index].tabId == tabId { @@ -100,6 +138,17 @@ final class TerminalNotificationStore: ObservableObject { } } + func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) { + let ids = notifications + .filter { $0.tabId == tabId && $0.surfaceId == surfaceId } + .map { $0.id.uuidString } + notifications.removeAll { $0.tabId == tabId && $0.surfaceId == surfaceId } + if !ids.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: ids) + center.removePendingNotificationRequests(withIdentifiers: ids) + } + } + private func scheduleUserNotification(_ notification: TerminalNotification) { ensureAuthorization { [weak self] authorized in guard let self, authorized else { return } diff --git a/tests/cmux.py b/tests/cmux.py index 708a5911..f606aea6 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -239,6 +239,70 @@ class cmux: """Get help text from server""" return self._send_command("help") + def notify(self, title: str, body: str = "") -> None: + """Create a notification for the focused surface.""" + payload = f"{title}|{body}" if body else title + response = self._send_command(f"notify {payload}") + if not response.startswith("OK"): + raise cmuxError(response) + + def notify_surface(self, surface: str | int, title: str, body: str = "") -> None: + """Create a notification for a specific surface by ID or index.""" + payload = f"{title}|{body}" if body else title + response = self._send_command(f"notify_surface {surface} {payload}") + if not response.startswith("OK"): + raise cmuxError(response) + + def list_notifications(self) -> list[dict]: + """ + List notifications. + Returns list of dicts with keys: id, tab_id, surface_id, is_read, title, body. + """ + response = self._send_command("list_notifications") + if response == "No notifications": + return [] + + items = [] + for line in response.split("\n"): + if not line.strip(): + continue + _, payload = line.split(":", 1) + parts = payload.split("|", 5) + if len(parts) < 6: + continue + notif_id, tab_id, surface_id, read_text, title, body = parts + items.append({ + "id": notif_id, + "tab_id": tab_id, + "surface_id": None if surface_id == "none" else surface_id, + "is_read": read_text == "read", + "title": title, + "body": body, + }) + return items + + def clear_notifications(self) -> None: + """Clear all notifications.""" + response = self._send_command("clear_notifications") + if not response.startswith("OK"): + raise cmuxError(response) + + def set_app_focus(self, active: bool | None) -> None: + """Override app focus state. Use None to clear override.""" + if active is None: + value = "clear" + else: + value = "active" if active else "inactive" + response = self._send_command(f"set_app_focus {value}") + if not response.startswith("OK"): + raise cmuxError(response) + + def simulate_app_active(self) -> None: + """Trigger the app active handler.""" + response = self._send_command("simulate_app_active") + if not response.startswith("OK"): + raise cmuxError(response) + def main(): """CLI interface for cmux""" diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 00000000..f5e8c090 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Automated tests for notification focus/suppression behavior. + +Usage: + python3 test_notifications.py + +Requirements: + - cmux must be running with the socket controller enabled +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError + + +class TestResult: + def __init__(self, name: str): + self.name = name + self.passed = False + self.message = "" + + def success(self, msg: str = ""): + self.passed = True + self.message = msg + + def failure(self, msg: str): + self.passed = False + self.message = msg + + +def wait_for_notifications(client: cmux, expected: int, timeout: float = 2.0) -> list[dict]: + start = time.time() + while time.time() - start < timeout: + items = client.list_notifications() + if len(items) == expected: + return items + time.sleep(0.05) + return client.list_notifications() + + +def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]: + surfaces = client.list_surfaces() + if len(surfaces) < 2: + client.new_split("right") + time.sleep(0.1) + surfaces = client.list_surfaces() + return surfaces + + +def test_clear_prior_notifications(client: cmux) -> TestResult: + result = TestResult("Clear Prior Panel Notifications") + try: + client.clear_notifications() + client.set_app_focus(False) + client.notify("first") + time.sleep(0.1) + client.notify("second") + items = wait_for_notifications(client, 1) + if len(items) != 1: + result.failure(f"Expected 1 notification, got {len(items)}") + elif items[0]["title"] != "second": + result.failure(f"Expected latest title 'second', got '{items[0]['title']}'") + else: + result.success("Prior panel notifications cleared") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_suppress_when_focused(client: cmux) -> TestResult: + result = TestResult("Suppress When App+Panel Focused") + try: + client.clear_notifications() + client.set_app_focus(True) + client.notify("focused") + items = wait_for_notifications(client, 0) + if len(items) == 0: + result.success("Suppressed notification when focused") + else: + result.failure(f"Expected 0 notifications, got {len(items)}") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_not_suppressed_when_inactive(client: cmux) -> TestResult: + result = TestResult("Allow When App Inactive") + try: + client.clear_notifications() + client.set_app_focus(False) + client.notify("inactive") + items = wait_for_notifications(client, 1) + if len(items) != 1: + result.failure(f"Expected 1 notification, got {len(items)}") + elif items[0]["is_read"]: + result.failure("Expected notification to be unread") + else: + result.success("Notification stored when app inactive") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_mark_read_on_focus_change(client: cmux) -> TestResult: + result = TestResult("Mark Read On Panel Focus") + try: + client.clear_notifications() + surfaces = ensure_two_surfaces(client) + focused = next((s for s in surfaces if s[2]), None) + other = next((s for s in surfaces if not s[2]), None) + if focused is None or other is None: + result.failure("Unable to identify focused and unfocused surfaces") + return result + + client.set_app_focus(False) + client.notify_surface(other[0], "focusread") + time.sleep(0.1) + + client.set_app_focus(True) + client.focus_surface(other[0]) + time.sleep(0.1) + + items = client.list_notifications() + target = next((n for n in items if n["surface_id"] == other[1]), None) + if target is None: + result.failure("Expected notification for target surface") + elif not target["is_read"]: + result.failure("Expected notification to be marked read on focus") + else: + result.success("Notification marked read on focus") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_mark_read_on_app_active(client: cmux) -> TestResult: + result = TestResult("Mark Read On App Active") + try: + client.clear_notifications() + client.set_app_focus(False) + client.notify("activate") + time.sleep(0.1) + + items = client.list_notifications() + if not items or items[0]["is_read"]: + result.failure("Expected unread notification before activation") + return result + + client.simulate_app_active() + time.sleep(0.1) + + items = client.list_notifications() + if not items: + result.failure("Expected notification to remain after activation") + elif not items[0]["is_read"]: + result.failure("Expected notification to be marked read on app active") + else: + result.success("Notification marked read on app active") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_mark_read_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Mark Read On Tab Switch") + try: + client.clear_notifications() + client.set_app_focus(False) + tab1 = client.current_tab() + client.notify("tabswitch") + time.sleep(0.1) + + tab2 = client.new_tab() + time.sleep(0.1) + + client.set_app_focus(True) + client.select_tab(tab1) + time.sleep(0.1) + + items = client.list_notifications() + target = next((n for n in items if n["tab_id"] == tab1), None) + if target is None: + result.failure("Expected notification for original tab") + elif not target["is_read"]: + result.failure("Expected notification to be marked read on tab switch") + else: + result.success("Notification marked read on tab switch") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def run_tests() -> int: + results = [] + with cmux() as client: + results.append(test_clear_prior_notifications(client)) + results.append(test_suppress_when_focused(client)) + results.append(test_not_suppressed_when_inactive(client)) + results.append(test_mark_read_on_focus_change(client)) + results.append(test_mark_read_on_app_active(client)) + results.append(test_mark_read_on_tab_switch(client)) + client.set_app_focus(None) + client.clear_notifications() + + print("\nNotification Tests:") + for r in results: + status = "PASS" if r.passed else "FAIL" + msg = f" - {r.message}" if r.message else "" + print(f"{status}: {r.name}{msg}") + + passed = sum(1 for r in results if r.passed) + total = len(results) + if passed == total: + print("\n🎉 All notification tests passed!") + return 0 + print(f"\n⚠️ {total - passed} test(s) failed") + return 1 + + +if __name__ == "__main__": + sys.exit(run_tests())