diff --git a/mac/VibeTunnel/Core/Services/WindowTracker.swift b/mac/VibeTunnel/Core/Services/WindowTracker.swift index bb1053e4..e5e491b2 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracker.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracker.swift @@ -401,6 +401,8 @@ final class WindowTracker { focusTerminalAppWindow(windowInfo) case .iTerm2: focusiTerm2Window(windowInfo) + case .ghostty: + focusGhosttyWindow(windowInfo) default: // For other terminals, use standard window focus focusWindowUsingAccessibility(windowInfo) @@ -549,17 +551,19 @@ final class WindowTracker { /// Focuses a Terminal.app window/tab. private func focusTerminalAppWindow(_ windowInfo: WindowInfo) { if let tabRef = windowInfo.tabReference { - // Use stored tab reference + // Use stored tab reference to select the tab + // The tabRef format is "tab id X of window id Y" let script = """ tell application "Terminal" activate - \(tabRef) + set selected of \(tabRef) to true + set frontmost of window id \(windowInfo.windowID) to true end tell """ do { try AppleScriptExecutor.shared.execute(script) - logger.info("Focused Terminal.app tab using reference") + logger.info("Focused Terminal.app tab using reference: \(tabRef)") } catch { logger.error("Failed to focus Terminal.app tab: \(error)") // Fallback to accessibility @@ -593,6 +597,7 @@ final class WindowTracker { private func focusiTerm2Window(_ windowInfo: WindowInfo) { if let windowID = windowInfo.tabID { // Use window ID for focusing (stored in tabID for consistency) + // iTerm2 uses 'select' to bring window to front let script = """ tell application "iTerm2" activate @@ -604,9 +609,10 @@ final class WindowTracker { do { try AppleScriptExecutor.shared.execute(script) - logger.info("Focused iTerm2 window using ID") + logger.info("Focused iTerm2 window using ID: \(windowID)") } catch { logger.error("Failed to focus iTerm2 window: \(error)") + // Fallback to accessibility focusWindowUsingAccessibility(windowInfo) } } else { @@ -615,6 +621,149 @@ final class WindowTracker { } } + /// Focuses a Ghostty window with macOS standard tabs. + private func focusGhosttyWindow(_ windowInfo: WindowInfo) { + // First bring the application to front + if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { + app.activate() + } + + // Ghostty uses macOS standard tabs, so we need to: + // 1. Focus the window + // 2. Find and select the correct tab + + // Use Accessibility API to handle tab selection + let axApp = AXUIElementCreateApplication(windowInfo.ownerPID) + + var windowsValue: CFTypeRef? + let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue) + + guard result == .success, + let windows = windowsValue as? [AXUIElement], + !windows.isEmpty + else { + logger.error("Failed to get windows for Ghostty") + focusWindowUsingAccessibility(windowInfo) + return + } + + // Find the matching window + for window in windows { + var windowIDValue: CFTypeRef? + if AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue) == .success, + let windowNumber = windowIDValue as? Int, + windowNumber == windowInfo.windowID + { + // Found the window, make it main and focused + AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) + AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) + + // Now look for tabs + var tabsValue: CFTypeRef? + if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success, + let tabs = tabsValue as? [AXUIElement], + !tabs.isEmpty + { + // Try to find the tab with matching session info + if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) { + let workingDir = sessionInfo.workingDir + let dirName = (workingDir as NSString).lastPathComponent + let sessionID = windowInfo.sessionID + let activityStatus = sessionInfo.activityStatus?.specificStatus?.status + + // First pass: Try to find exact match with session ID in title + for (index, tab) in tabs.enumerated() { + var titleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + { + // Check if tab title contains the session ID (most precise match) + if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") { + // Select this tab + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected Ghostty tab \(index) by session ID match for session \(sessionID)") + return + } + } + } + + // Second pass: Try to match by activity status if available + if let activity = activityStatus, !activity.isEmpty { + for (index, tab) in tabs.enumerated() { + var titleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + { + // Check if tab title contains the activity string + if title.contains(activity) { + // Also verify it's in the right directory if possible + if title.contains(dirName) || title.contains(workingDir) { + // Select this tab + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected Ghostty tab \(index) by activity match '\(activity)' for session \(sessionID)") + return + } + } + } + } + } + + // Third pass: Try to match by command if available + if let command = sessionInfo.command.first, !command.isEmpty { + for (index, tab) in tabs.enumerated() { + var titleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + { + // Check if tab title contains the command + if title.contains(command) { + // Also verify it's in the right directory if possible + if title.contains(dirName) || title.contains(workingDir) { + // Select this tab + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected Ghostty tab \(index) by command match for session \(sessionID)") + return + } + } + } + } + } + + // Fourth pass: Try to match by working directory only + for (index, tab) in tabs.enumerated() { + var titleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + { + // Check if tab title contains the working directory + if title.contains(dirName) || title.contains(workingDir) { + // Select this tab + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected Ghostty tab \(index) by directory match for session \(sessionID)") + return + } + } + } + + // If no matching tab found, select the most recently created tab + // (assuming it's the last one if we just created it) + if let lastTab = tabs.last { + AXUIElementPerformAction(lastTab, kAXPressAction as CFString) + logger.info("Selected last Ghostty tab as fallback for session \(sessionID)") + } + } + } + + logger.info("Focused Ghostty window using Accessibility API") + return + } + } + + // Fallback if we couldn't find the specific window + logger.warning("Could not find matching Ghostty window, using fallback") + focusWindowUsingAccessibility(windowInfo) + } + /// Focuses a window using Accessibility APIs. private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) { // First bring the application to front @@ -647,6 +796,53 @@ final class WindowTracker { // Found the matching window, make it main and focused AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) + + // For terminals that use macOS standard tabs, try to select the correct tab + var tabsValue: CFTypeRef? + if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success, + let tabs = tabsValue as? [AXUIElement], + !tabs.isEmpty + { + logger.info("Terminal has \(tabs.count) tabs, attempting to find correct one") + + // Try to find the tab with matching session info + if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) { + let workingDir = sessionInfo.workingDir + let dirName = (workingDir as NSString).lastPathComponent + let sessionID = windowInfo.sessionID + let activityStatus = sessionInfo.activityStatus?.specificStatus?.status + + // Try multiple matching strategies + for (index, tab) in tabs.enumerated() { + var titleValue: CFTypeRef? + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + { + // Check for session ID match first (most precise) + if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") { + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)") + return + } + + // Check for activity status match (unique for dynamic activities) + if let activity = activityStatus, !activity.isEmpty, title.contains(activity) { + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)") + return + } + + // Check for directory match + if title.contains(dirName) || title.contains(workingDir) { + AXUIElementPerformAction(tab, kAXPressAction as CFString) + logger.info("Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)") + return + } + } + } + } + } + logger.info("Focused window using Accessibility API") return } diff --git a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift index 4aebe6a4..e4e3333b 100644 --- a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift +++ b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift @@ -35,7 +35,7 @@ final class CustomMenuWindow: NSPanel { // Initialize window with appropriate style super.init( contentRect: NSRect(x: 0, y: 0, width: 384, height: 400), - styleMask: [.borderless, .nonactivatingPanel, .utilityWindow], + styleMask: [.borderless, .utilityWindow], backing: .buffered, defer: false ) @@ -45,10 +45,14 @@ final class CustomMenuWindow: NSPanel { backgroundColor = .clear hasShadow = true level = .popUpMenu - collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] isMovableByWindowBackground = false hidesOnDeactivate = false isReleasedWhenClosed = false + + // Allow the window to become key but not main + // This helps maintain button highlight state + acceptsMouseMovedEvents = false // Set content view controller contentViewController = hostingController @@ -56,11 +60,27 @@ final class CustomMenuWindow: NSPanel { // Force the view to load immediately _ = hostingController.view - // Add visual effect background with rounded corners + // Add visual effect background with custom shape if let contentView = contentViewController?.view { contentView.wantsLayer = true - contentView.layer?.cornerRadius = 12 - contentView.layer?.masksToBounds = true + + // Create a custom mask layer for side-rounded corners + let maskLayer = CAShapeLayer() + maskLayer.path = createSideRoundedPath(in: contentView.bounds, cornerRadius: 12) + contentView.layer?.mask = maskLayer + + // Update mask when bounds change + contentView.postsFrameChangedNotifications = true + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: contentView, + queue: .main + ) { [weak self, weak contentView] _ in + guard let self = self, let contentView = contentView else { return } + Task { @MainActor in + maskLayer.path = self.createSideRoundedPath(in: contentView.bounds, cornerRadius: 12) + } + } // Add subtle shadow contentView.shadow = NSShadow() @@ -73,7 +93,7 @@ final class CustomMenuWindow: NSPanel { func show(relativeTo statusItemButton: NSStatusBarButton) { // Store button reference and ensure it stays highlighted self.statusBarButton = statusItemButton - statusItemButton.state = .on + statusItemButton.highlight(true) // First, make sure the SwiftUI hierarchy has laid itself out hostingController.view.layoutSubtreeIfNeeded() @@ -134,16 +154,17 @@ final class CustomMenuWindow: NSPanel { // Set all visual properties at once alphaValue = 1.0 - // Ensure button state remains on - statusBarButton?.state = .on + // Ensure button remains highlighted + statusBarButton?.highlight(true) - // Activate app and show window - NSApp.activate(ignoringOtherApps: true) - makeKeyAndOrderFront(nil) + // Show window without activating the app aggressively + // This helps maintain the button's highlight state + orderFront(nil) + makeKey() - // Force button state update again after window is shown + // Force button highlight update again after window is shown DispatchQueue.main.async { [weak self] in - self?.statusBarButton?.state = .on + self?.statusBarButton?.highlight(true) } // Set first responder after window is visible @@ -238,8 +259,8 @@ final class CustomMenuWindow: NSPanel { // Mark window as not visible _isWindowVisible = false - // Reset button state when hiding - statusBarButton?.state = .off + // Reset button highlight when hiding + statusBarButton?.highlight(false) orderOut(nil) teardownEventMonitoring() onHide?() @@ -251,8 +272,8 @@ final class CustomMenuWindow: NSPanel { // Mark window as not visible _isWindowVisible = false - // Reset button state when window is ordered out - statusBarButton?.state = .off + // Reset button highlight when window is ordered out + statusBarButton?.highlight(false) onHide?() } @@ -306,12 +327,67 @@ final class CustomMenuWindow: NSPanel { teardownEventMonitoring() } } + + private func createSideRoundedPath(in rect: CGRect, cornerRadius: CGFloat) -> CGPath { + let path = CGMutablePath() + + // Start from top-left corner (flat) + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + + // Top edge (flat) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + + // Right edge with rounded corners + path.addArc( + center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius), + radius: cornerRadius, + startAngle: -CGFloat.pi / 2, + endAngle: 0, + clockwise: false + ) + + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius)) + + path.addArc( + center: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: 0, + endAngle: CGFloat.pi / 2, + clockwise: false + ) + + // Bottom edge (flat) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + + // Left edge with rounded corners + path.addArc( + center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: CGFloat.pi / 2, + endAngle: CGFloat.pi, + clockwise: false + ) + + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) + + path.addArc( + center: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius), + radius: cornerRadius, + startAngle: CGFloat.pi, + endAngle: 3 * CGFloat.pi / 2, + clockwise: false + ) + + path.closeSubpath() + + return path + } } + /// A wrapper view that applies modern SwiftUI material background to menu content. struct CustomMenuContainer: View { - @ViewBuilder - let content: Content + @ViewBuilder let content: Content @Environment(\.colorScheme) private var colorScheme @@ -319,9 +395,9 @@ struct CustomMenuContainer: View { var body: some View { content .fixedSize() - .background(backgroundMaterial, in: RoundedRectangle(cornerRadius: 12)) + .background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12)) .overlay( - RoundedRectangle(cornerRadius: 12) + SideRoundedRectangle(cornerRadius: 12) .stroke(borderColor, lineWidth: 1) ) } diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index db96321b..77e86179 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -292,7 +292,7 @@ struct NewSessionForm: View { .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 6) - .fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color.accentColor) + .fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(red: 0.2, green: 0.6, blue: 0.3)) ) .disabled(isCreating || command.isEmpty || workingDirectory.isEmpty) } @@ -301,6 +301,7 @@ struct NewSessionForm: View { } .frame(width: 384) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) .fixedSize(horizontal: true, vertical: false) .onAppear { loadPreferences() diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift index 82c2de2c..c6ee4839 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift @@ -63,8 +63,10 @@ final class StatusBarController: NSObject { button.action = #selector(handleClick(_:)) button.target = self button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + + // Use pushOnPushOff for proper state management button.setButtonType(.pushOnPushOff) - + // Accessibility button.setAccessibilityTitle("VibeTunnel") button.setAccessibilityRole(.button) @@ -126,9 +128,6 @@ final class StatusBarController: NSObject { func updateStatusItemDisplay() { guard let button = statusItem?.button else { return } - // Check if any menu is visible to preserve highlight state - let shouldBeHighlighted = menuManager.isAnyMenuVisible - // Update icon based on server and network status let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive" if let image = NSImage(named: iconName) { @@ -143,13 +142,6 @@ final class StatusBarController: NSObject { } } - // With .pushOnPushOff button type, the button manages its own highlight state - // We only need to update the state when the menu visibility changes - let expectedState: NSControl.StateValue = shouldBeHighlighted ? .on : .off - if button.state != expectedState { - button.state = expectedState - } - // Update session count display let sessions = sessionMonitor.sessions.values.filter(\.isRunning) let activeSessions = sessions.filter { session in @@ -174,11 +166,6 @@ final class StatusBarController: NSObject { let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle) button.title = indicator.isEmpty ? "" : " " + indicator - // Update button state after title change if needed - if shouldBeHighlighted && button.state != .on { - button.state = .on - } - // Update tooltip updateTooltip() } @@ -259,12 +246,6 @@ final class StatusBarController: NSObject { private func handleLeftClick(_ button: NSStatusBarButton) { menuManager.toggleCustomWindow(relativeTo: button) - - // Force update display after toggling to ensure button state is correct - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(50)) - updateStatusItemDisplay() - } } private func handleRightClick(_ button: NSStatusBarButton) { diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index eab0ba81..3fb53825 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -69,13 +69,7 @@ final class StatusBarMenuManager: NSObject { statusBarButton = button } - // Update button state based on menu state (for .pushOnPushOff button type) - switch menuState { - case .none: - statusBarButton?.state = .off - case .customWindow, .contextMenu: - statusBarButton?.state = .on - } + // No need to manage highlight here since we're using button state } // MARK: - Left-Click Custom Window Management @@ -83,8 +77,6 @@ final class StatusBarMenuManager: NSObject { func toggleCustomWindow(relativeTo button: NSStatusBarButton) { if let window = customWindow, window.isVisible { hideCustomWindow() - // Ensure button state is updated - button.state = .off } else { showCustomWindow(relativeTo: button) } @@ -153,19 +145,19 @@ final class StatusBarMenuManager: NSObject { // Show the custom window customWindow?.show(relativeTo: button) - // Force immediate button state update after showing window + // Force immediate button highlight update after showing window // This ensures the button stays highlighted even if there's a timing issue Task { @MainActor in try? await Task.sleep(for: .milliseconds(10)) - button.state = .on + button.highlight(true) } } func hideCustomWindow() { customWindow?.hide() - // Note: state will be reset by the onHide callback - // But also ensure button state is updated immediately + // Reset button state statusBarButton?.state = .off + // Note: state will be reset by the onHide callback } var isCustomWindowVisible: Bool { @@ -195,11 +187,14 @@ final class StatusBarMenuManager: NSObject { // Hide custom window first if it's visible hideCustomWindow() - // Update menu state to context menu - updateMenuState(.contextMenu, button: button) - // Store status item reference currentStatusItem = statusItem + + // Set the button's state to on for context menu + button.state = .on + + // Update menu state to context menu + updateMenuState(.contextMenu, button: button) let menu = NSMenu() menu.delegate = self @@ -282,9 +277,6 @@ final class StatusBarMenuManager: NSObject { // Show the context menu // Use popUpMenu for proper context menu display that doesn't interfere with button highlighting menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 5), in: button) - - // Update state to indicate no menu is active after context menu closes - updateMenuState(.none, button: button) } // MARK: - Context Menu Actions @@ -362,6 +354,9 @@ final class StatusBarMenuManager: NSObject { extension StatusBarMenuManager: NSMenuDelegate { func menuDidClose(_ menu: NSMenu) { + // Reset button state + statusBarButton?.state = .off + // Reset menu state when context menu closes updateMenuState(.none) diff --git a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift index 14288155..87e08264 100644 --- a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift +++ b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift @@ -496,7 +496,7 @@ struct SessionRow: View { HStack(spacing: 4) { Text(activityStatus) .font(.system(size: 10)) - .foregroundColor(Color(red: 1.0, green: 0.5, blue: 0.0)) + .foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0)) Spacer(minLength: 4) @@ -548,7 +548,7 @@ struct SessionRow: View { .transition(.opacity) } } - .frame(width: 35, alignment: .trailing) + .frame(width: 40, alignment: .trailing) .animation(.easeInOut(duration: 0.15), value: isHovered) } .padding(.horizontal, 12) @@ -704,7 +704,7 @@ struct SessionRow: View { private var activityColor: Color { if isActive { - Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange + Color(red: 0.8, green: 0.4, blue: 0.0) // Darker orange for better contrast } else { Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green } diff --git a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift index 9c481915..3d9602b0 100644 --- a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift +++ b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift @@ -367,7 +367,7 @@ struct SessionRowView: View { if let activityStatus { Text(activityStatus) .font(.system(size: 11)) - .foregroundColor(.orange) + .foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0)) Text("ยท") .font(.system(size: 11))