Fix notification focus and indicators

This commit is contained in:
Lawrence Chen 2026-01-28 15:15:34 -08:00
parent 4b01de1ba9
commit c353131f53
7 changed files with 297 additions and 24 deletions

View file

@ -52,7 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if let surfaceId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false)
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}

View file

@ -1528,6 +1528,21 @@ final class GhosttySurfaceScrollView: NSView {
private var lastSentRow: Int?
private var isActive = true
private var focusWorkItem: DispatchWorkItem?
#if DEBUG
private static var flashCounts: [UUID: Int] = [:]
static func flashCount(for surfaceId: UUID) -> Int {
flashCounts[surfaceId, default: 0]
}
static func resetFlashCounts() {
flashCounts.removeAll()
}
private static func recordFlash(for surfaceId: UUID) {
flashCounts[surfaceId, default: 0] += 1
}
#endif
init(surfaceView: GhosttyNSView) {
self.surfaceView = surfaceView
@ -1712,6 +1727,11 @@ final class GhosttySurfaceScrollView: NSView {
func triggerFlash() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
#if DEBUG
if let surfaceId = self.surfaceView.terminalSurface?.id {
Self.recordFlash(for: surfaceId)
}
#endif
self.updateFlashPath()
self.flashLayer.removeAllAnimations()
self.flashLayer.opacity = 0

View file

@ -4,6 +4,7 @@ struct TerminalSplitTreeView: View {
@ObservedObject var tab: Tab
let isTabActive: Bool
@State private var config = GhosttyConfig.load()
@EnvironmentObject var notificationStore: TerminalNotificationStore
var body: some View {
let appearance = SplitAppearance(
@ -20,6 +21,8 @@ struct TerminalSplitTreeView: View {
isTabActive: isTabActive,
focusedSurfaceId: tab.focusedSurfaceId,
appearance: appearance,
tabId: tab.id,
notificationStore: notificationStore,
onFocus: { tab.focusSurface($0) },
onTriggerFlash: { tab.triggerDebugFlash(surfaceId: $0) },
onResize: { tab.updateSplitRatio(node: $0, ratio: $1) },
@ -44,6 +47,8 @@ fileprivate struct TerminalSplitSubtreeView: View {
let isTabActive: Bool
let focusedSurfaceId: UUID?
let appearance: SplitAppearance
let tabId: UUID
let notificationStore: TerminalNotificationStore
let onFocus: (UUID) -> Void
let onTriggerFlash: (UUID) -> Void
let onResize: (SplitTree<TerminalSurface>.Node, Double) -> Void
@ -53,7 +58,7 @@ fileprivate struct TerminalSplitSubtreeView: View {
switch node {
case .leaf(let surface):
let isFocused = isTabActive && focusedSurfaceId == surface.id
ZStack {
ZStack(alignment: .topLeading) {
GhosttyTerminalView(
terminalSurface: surface,
isActive: isFocused,
@ -62,6 +67,15 @@ fileprivate struct TerminalSplitSubtreeView: View {
)
.background(Color.clear)
if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) {
Circle()
.stroke(Color(nsColor: .systemBlue), lineWidth: 2.5)
.frame(width: 14, height: 14)
.shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 2)
.padding(6)
.allowsHitTesting(false)
}
if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 {
Rectangle()
.fill(appearance.unfocusedOverlayColor)
@ -92,6 +106,8 @@ fileprivate struct TerminalSplitSubtreeView: View {
isTabActive: isTabActive,
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
tabId: tabId,
notificationStore: notificationStore,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,
@ -106,6 +122,8 @@ fileprivate struct TerminalSplitSubtreeView: View {
isTabActive: isTabActive,
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
tabId: tabId,
notificationStore: notificationStore,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,

View file

@ -7,7 +7,12 @@ class Tab: Identifiable, ObservableObject {
@Published var title: String
@Published var currentDirectory: String
@Published var splitTree: SplitTree<TerminalSurface>
@Published var focusedSurfaceId: UUID?
@Published var focusedSurfaceId: UUID? {
didSet {
guard let focusedSurfaceId else { return }
AppDelegate.shared?.tabManager?.rememberFocusedSurface(tabId: id, surfaceId: focusedSurfaceId)
}
}
@Published var surfaceDirectories: [UUID: String] = [:]
var splitViewSize: CGSize = .zero
@ -33,7 +38,7 @@ class Tab: Identifiable, ObservableObject {
return nil
}
func focusSurface(_ id: UUID, shouldFlash: Bool = true) {
func focusSurface(_ id: UUID) {
let wasFocused = focusedSurfaceId == id
focusedSurfaceId = id
let isSelectedTab = AppDelegate.shared?.tabManager?.selectedTabId == self.id
@ -44,9 +49,6 @@ class Tab: Identifiable, ObservableObject {
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
}
@ -64,17 +66,26 @@ class Tab: Identifiable, ObservableObject {
currentDirectory = trimmed
}
func triggerNotificationFocusFlash(surfaceId: UUID, requiresSplit: Bool = false) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit)
func triggerNotificationFocusFlash(
surfaceId: UUID,
requiresSplit: Bool = false,
shouldFocus: Bool = true
) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit, shouldFocus: shouldFocus)
}
func triggerDebugFlash(surfaceId: UUID) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false)
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: true)
}
private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool) {
private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool, shouldFocus: Bool) {
guard let surface = surface(for: surfaceId) else { return }
focusSurface(surfaceId)
if shouldFocus {
if focusedSurfaceId != surfaceId {
focusSurface(surfaceId)
}
surface.hostedView.ensureFocus(for: self.id, surfaceId: surfaceId)
}
if requiresSplit && !splitTree.isSplit {
return
}
@ -249,6 +260,10 @@ class TabManager: ObservableObject {
didSet {
guard selectedTabId != oldValue else { return }
let previousTabId = oldValue
if let previousTabId,
let previousSurfaceId = focusedSurfaceId(for: previousTabId) {
lastFocusedSurfaceByTab[previousTabId] = previousSurfaceId
}
DispatchQueue.main.async { [weak self] in
self?.focusSelectedTabSurface(previousTabId: previousTabId)
self?.updateWindowTitleForSelectedTab()
@ -260,6 +275,7 @@ class TabManager: ObservableObject {
}
private var observers: [NSObjectProtocol] = []
private var suppressFocusFlash = false
private var lastFocusedSurfaceByTab: [UUID: UUID] = [:]
init() {
addTab()
@ -415,6 +431,10 @@ class TabManager: ObservableObject {
tabs.first(where: { $0.id == tabId })?.focusedSurfaceId
}
func rememberFocusedSurface(tabId: UUID, surfaceId: UUID) {
lastFocusedSurfaceByTab[tabId] = surfaceId
}
func applyWindowBackgroundForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
@ -424,8 +444,13 @@ class TabManager: ObservableObject {
private func focusSelectedTabSurface(previousTabId: UUID?) {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let surface = tab.focusedSurface else { return }
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
if let restoredSurfaceId = lastFocusedSurfaceByTab[selectedTabId],
tab.surface(for: restoredSurfaceId) != nil,
tab.focusedSurfaceId != restoredSurfaceId {
tab.focusedSurfaceId = restoredSurfaceId
}
guard let surface = tab.focusedSurface else { return }
let previousSurface = previousTabId.flatMap { id in
tabs.first(where: { $0.id == id })?.focusedSurface
}
@ -436,14 +461,11 @@ class TabManager: ObservableObject {
private func markFocusedPanelReadIfActive(tabId: UUID) {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
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)
}
@ -501,14 +523,18 @@ class TabManager: ObservableObject {
}
if let surfaceId {
focusSurface(tabId: tabId, surfaceId: surfaceId, shouldFlash: !suppressFlash)
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else if let tab = tabs.first(where: { $0.id == tabId }) {
tab.focusedSurfaceId = surfaceId
}
}
}
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
let wasSelected = selectedTabId == tabId
suppressFocusFlash = true
focusTab(tabId, surfaceId: surfaceId)
focusTab(tabId)
if wasSelected {
suppressFocusFlash = false
}
@ -521,13 +547,14 @@ class TabManager: ObservableObject {
tab.surface(for: targetSurfaceId) != nil else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetSurfaceId) else { return }
tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false)
tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetSurfaceId)
}
}
func focusSurface(tabId: UUID, surfaceId: UUID, shouldFlash: Bool = true) {
func focusSurface(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.focusSurface(surfaceId, shouldFlash: shouldFlash)
tab.focusSurface(surfaceId)
}
func selectNextTab() {

View file

@ -194,6 +194,17 @@ class TerminalController {
case "simulate_app_active":
return simulateAppDidBecomeActive()
#if DEBUG
case "focus_notification":
return focusFromNotification(args)
case "flash_count":
return flashCount(args)
case "reset_flash_counts":
return resetFlashCounts()
#endif
case "help":
return helpText()
@ -203,7 +214,7 @@ class TerminalController {
}
private func helpText() -> String {
return """
var text = """
Available commands:
ping - Check if server is running
list_tabs - List all tabs with IDs
@ -226,6 +237,15 @@ class TerminalController {
simulate_app_active - Trigger app active handler
help - Show this help
"""
#if DEBUG
text += """
focus_notification <tab|idx> [surface|idx] - Focus via notification flow
flash_count <id|idx> - Read flash count for a surface
reset_flash_counts - Reset flash counters
"""
#endif
return text
}
private func listTabs() -> String {
@ -420,6 +440,60 @@ class TerminalController {
return "OK"
}
#if DEBUG
private func focusFromNotification(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
let tabArg = parts.first ?? ""
let surfaceArg = parts.count > 1 ? parts[1] : ""
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab)
if !surfaceArg.isEmpty && surfaceId == nil {
result = "ERROR: Surface not found"
return
}
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
}
return result
}
private func flashCount(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
var result = "ERROR: Surface not found"
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: trimmed, tab: tab) else {
result = "ERROR: Surface not found"
return
}
let count = GhosttySurfaceScrollView.flashCount(for: surfaceId)
result = "OK \(count)"
}
return result
}
private func resetFlashCounts() -> String {
DispatchQueue.main.sync {
GhosttySurfaceScrollView.resetFlashCounts()
}
return "OK"
}
#endif
private func parseSplitDirection(_ value: String) -> SplitTree<TerminalSurface>.NewDirection? {
switch value.lowercased() {
case "left", "l":

View file

@ -303,6 +303,29 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def focus_notification(self, tab: str | int, surface: str | int | None = None) -> None:
"""Focus tab/surface using the notification flow."""
if surface is None:
command = f"focus_notification {tab}"
else:
command = f"focus_notification {tab} {surface}"
response = self._send_command(command)
if not response.startswith("OK"):
raise cmuxError(response)
def flash_count(self, surface: str | int) -> int:
"""Get flash count for a surface by ID or index."""
response = self._send_command(f"flash_count {surface}")
if response.startswith("OK "):
return int(response.split(" ", 1)[1])
raise cmuxError(response)
def reset_flash_counts(self) -> None:
"""Reset flash counters."""
response = self._send_command("reset_flash_counts")
if not response.startswith("OK"):
raise cmuxError(response)
def main():
"""CLI interface for cmux"""

View file

@ -195,6 +195,114 @@ def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
return result
def test_no_flash_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("No Flash On Tab Switch")
try:
client.clear_notifications()
client.reset_flash_counts()
tab1 = client.current_tab()
surfaces = client.list_surfaces()
focused = next((s for s in surfaces if s[2]), None)
if focused is None:
result.failure("Unable to identify focused surface")
return result
client.set_app_focus(False)
client.notify("tabswitchflash")
time.sleep(0.1)
client.new_tab()
time.sleep(0.1)
client.set_app_focus(True)
client.select_tab(tab1)
time.sleep(0.2)
count = client.flash_count(focused[1])
if count != 0:
result.failure(f"Expected flash count 0, got {count}")
else:
result.success("No flash triggered on tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_focus_on_notification_click(client: cmux) -> TestResult:
result = TestResult("Focus On Notification Click")
try:
client.clear_notifications()
client.reset_flash_counts()
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], "notifyfocus")
time.sleep(0.1)
client.set_app_focus(True)
tab_id = client.current_tab()
client.focus_notification(tab_id, other[0])
time.sleep(0.2)
surfaces = client.list_surfaces()
target = next((s for s in surfaces if s[1] == other[1]), None)
if target is None or not target[2]:
result.failure("Expected notification surface to be focused")
return result
count = client.flash_count(other[1])
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses and flashes panel")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_restore_focus_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Restore Focus On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(True)
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.focus_surface(other[0])
time.sleep(0.1)
tab1 = client.current_tab()
client.new_tab()
time.sleep(0.1)
client.select_tab(tab1)
time.sleep(0.2)
surfaces = client.list_surfaces()
target = next((s for s in surfaces if s[1] == other[1]), None)
if target is None:
result.failure("Unable to find previously focused surface")
elif not target[2]:
result.failure("Expected previously focused surface to be focused after tab switch")
else:
result.success("Restored last focused surface after tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def run_tests() -> int:
results = []
with cmux() as client:
@ -204,6 +312,9 @@ def run_tests() -> int:
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))
results.append(test_no_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))
client.set_app_focus(None)
client.clear_notifications()