diff --git a/CHANGELOG.md b/CHANGELOG.md index 43941e36..cc497c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [1.0.0-beta.7] - 2025-07-03 + +### ✨ New Features + +**Magic Wand for AI Assistants** +- **Instant Terminal Title Updates** - New magic wand button (🪄) appears when hovering over AI assistant sessions +- **Universal AI Support** - Works with Claude, Gemini, GPT, and other AI command-line tools - not just Claude anymore +- **One-Click Status Updates** - Clicking the wand prompts your AI assistant to update the terminal title with what it's currently working on +- **Smart Detection** - Automatically detects AI sessions by recognizing common command names + +**Window Highlight Live Preview** +- **See Before You Save** - Window highlight settings now show a live preview on the Settings window itself +- **Instant Feedback** - Preview updates immediately as you change highlight styles or colors +- **Try Before Apply** - Test different highlight styles (Default, Subtle, Neon, Custom) without leaving settings + +**Enhanced Terminal Support** +- **Ghostty Integration** - Added full support for Ghostty terminal - windows now close automatically when sessions end +- **Complete Terminal Coverage** - VibeTunnel now supports automatic window management for Terminal.app, iTerm2, and Ghostty + +### 🐛 Bug Fixes + +**Window Management** +- **Accurate Window Focus** - Fixed issues where the wrong terminal window would be highlighted when switching sessions +- **Better Multi-Display Support** - Window highlights now position correctly on external monitors +- **Consistent Green Highlights** - Changed highlight color from purple to green to match the web interface +- **Auto-Close Fixed** - Terminal windows now properly close when sessions exit naturally (not just when manually stopped) + +**Magic Wand Reliability** +- **Proper Command Submission** - Fixed magic wand commands not being executed properly - now sends both the prompt and Enter key correctly +- **No More Race Conditions** - Added input queue protection to ensure commands are sent in the correct order +- **Works Every Time** - Magic wand prompts now reliably trigger terminal title updates + +**General Improvements** +- **Quieter Logs** - Reduced verbosity of terminal title update logs - less noise in debug output +- **Swift 6 Compatibility** - Fixed all concurrency and syntax errors for latest Swift compiler +- **Cleaner UI** - Reorganized settings to put Window Highlight options in a more logical location + ## [1.0.0-beta.5] - upcoming ### 🎯 Features diff --git a/ios/VibeTunnel/version.xcconfig b/ios/VibeTunnel/version.xcconfig index 1fa4734b..d0bbe034 100644 --- a/ios/VibeTunnel/version.xcconfig +++ b/ios/VibeTunnel/version.xcconfig @@ -1,8 +1,8 @@ // VibeTunnel Version Configuration // This file contains the version and build number for the app -MARKETING_VERSION = 1.0.0-beta.6 -CURRENT_PROJECT_VERSION = 152 +MARKETING_VERSION = 1.0.0-beta.7 +CURRENT_PROJECT_VERSION = 160 // Domain and GitHub configuration APP_DOMAIN = vibetunnel.sh diff --git a/mac/VibeTunnel/Core/Accessibility/AXElement.swift b/mac/VibeTunnel/Core/Accessibility/AXElement.swift new file mode 100644 index 00000000..c379ecc4 --- /dev/null +++ b/mac/VibeTunnel/Core/Accessibility/AXElement.swift @@ -0,0 +1,404 @@ +import ApplicationServices +import Foundation +import OSLog + +/// A Swift-friendly wrapper around AXUIElement that simplifies accessibility operations. +/// This is a minimal implementation inspired by AXorcist but tailored for VibeTunnel's needs. +public struct AXElement: Equatable, Hashable, @unchecked Sendable { + // MARK: - Properties + + /// The underlying AXUIElement + public let element: AXUIElement + + private let logger = Logger( + subsystem: "sh.vibetunnel.vibetunnel", + category: "AXElement" + ) + + // MARK: - Initialization + + /// Creates an AXElement wrapper around an AXUIElement + public init(_ element: AXUIElement) { + self.element = element + } + + // MARK: - Factory Methods + + /// Creates an element for the system-wide accessibility object + public static var systemWide: AXElement { + AXElement(AXUIElementCreateSystemWide()) + } + + /// Creates an element for an application with the given process ID + public static func application(pid: pid_t) -> AXElement { + AXElement(AXUIElementCreateApplication(pid)) + } + + // MARK: - Attribute Access + + /// Gets a string attribute value + public func string(for attribute: String) -> String? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success, + let stringValue = value as? String else { + return nil + } + + return stringValue + } + + /// Gets a boolean attribute value + public func bool(for attribute: String) -> Bool? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success else { return nil } + + if let boolValue = value as? Bool { + return boolValue + } + + // Handle CFBoolean + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return CFBooleanGetValue(value as! CFBoolean) + } + + return nil + } + + /// Gets an integer attribute value + public func int(for attribute: String) -> Int? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success, + let number = value as? NSNumber else { + return nil + } + + return number.intValue + } + + /// Gets a CGPoint attribute value + public func point(for attribute: String) -> CGPoint? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success else { return nil } + + var point = CGPoint.zero + if AXValueGetValue(value as! AXValue, .cgPoint, &point) { + return point + } + + return nil + } + + /// Gets a CGSize attribute value + public func size(for attribute: String) -> CGSize? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success else { return nil } + + var size = CGSize.zero + if AXValueGetValue(value as! AXValue, .cgSize, &size) { + return size + } + + return nil + } + + /// Gets a CGRect by combining position and size attributes + public func frame() -> CGRect? { + guard let position = point(for: kAXPositionAttribute), + let size = size(for: kAXSizeAttribute) else { + return nil + } + + return CGRect(origin: position, size: size) + } + + /// Gets an AXUIElement attribute value + public func element(for attribute: String) -> AXElement? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success, + CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + + return AXElement(value as! AXUIElement) + } + + /// Gets an array of AXUIElement attribute values + public func elements(for attribute: String) -> [AXElement]? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success, + let array = value as? [AXUIElement] else { + return nil + } + + return array.map { AXElement($0) } + } + + /// Gets the raw attribute value as CFTypeRef + public func rawValue(for attribute: String) -> CFTypeRef? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + + guard result == .success else { return nil } + + return value + } + + // MARK: - Attribute Setting + + /// Sets an attribute value + @discardableResult + public func setAttribute(_ attribute: String, value: CFTypeRef) -> AXError { + AXUIElementSetAttributeValue(element, attribute as CFString, value) + } + + /// Sets a boolean attribute + @discardableResult + public func setBool(_ attribute: String, value: Bool) -> AXError { + setAttribute(attribute, value: value as CFBoolean) + } + + /// Sets a CGPoint attribute + @discardableResult + public func setPoint(_ attribute: String, value: CGPoint) -> AXError { + var mutableValue = value + guard let axValue = AXValueCreate(.cgPoint, &mutableValue) else { + return .failure + } + return setAttribute(attribute, value: axValue) + } + + /// Sets a CGSize attribute + @discardableResult + public func setSize(_ attribute: String, value: CGSize) -> AXError { + var mutableValue = value + guard let axValue = AXValueCreate(.cgSize, &mutableValue) else { + return .failure + } + return setAttribute(attribute, value: axValue) + } + + // MARK: - Actions + + /// Performs an action on the element + @discardableResult + public func performAction(_ action: String) -> AXError { + AXUIElementPerformAction(element, action as CFString) + } + + /// Gets the list of supported actions + public func actions() -> [String]? { + var actions: CFArray? + let result = AXUIElementCopyActionNames(element, &actions) + + guard result == .success, + let actionArray = actions as? [String] else { + return nil + } + + return actionArray + } + + // MARK: - Common Attributes + + /// Gets the role of the element + public var role: String? { + string(for: kAXRoleAttribute) + } + + /// Gets the title of the element + public var title: String? { + string(for: kAXTitleAttribute) + } + + /// Gets the value of the element + public var value: Any? { + rawValue(for: kAXValueAttribute) + } + + /// Gets the position of the element + public var position: CGPoint? { + point(for: kAXPositionAttribute) + } + + /// Gets the size of the element + public var size: CGSize? { + size(for: kAXSizeAttribute) + } + + /// Gets the focused state of the element + public var isFocused: Bool { + bool(for: kAXFocusedAttribute) ?? false + } + + /// Gets the enabled state of the element + public var isEnabled: Bool { + bool(for: kAXEnabledAttribute) ?? true + } + + /// Gets the window ID (for window elements) + public var windowID: Int? { + int(for: "_AXWindowNumber") + } + + // MARK: - Hierarchy + + /// Gets the parent element + public var parent: AXElement? { + element(for: kAXParentAttribute) + } + + /// Gets the children elements + public var children: [AXElement]? { + elements(for: kAXChildrenAttribute) + } + + /// Gets the windows (for application elements) + public var windows: [AXElement]? { + elements(for: kAXWindowsAttribute) + } + + // MARK: - Parameterized Attributes + + /// Checks if an attribute is settable + public func isAttributeSettable(_ attribute: String) -> Bool { + var settable: DarwinBoolean = false + let result = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) + return result == .success && settable.boolValue + } + + // MARK: - Equatable & Hashable + + public static func == (lhs: AXElement, rhs: AXElement) -> Bool { + CFEqual(lhs.element, rhs.element) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(CFHash(element)) + } +} + +// MARK: - Common Actions + +public extension AXElement { + /// Presses the element (for buttons, etc.) + @discardableResult + func press() -> Bool { + performAction(kAXPressAction) == .success + } + + /// Raises the element (for windows) + @discardableResult + func raise() -> Bool { + performAction(kAXRaiseAction) == .success + } + + /// Shows the menu for the element + @discardableResult + func showMenu() -> Bool { + performAction(kAXShowMenuAction) == .success + } +} + +// MARK: - Window-specific Operations + +public extension AXElement { + /// Checks if this is a window element + var isWindow: Bool { + role == kAXWindowRole + } + + /// Checks if the window is minimized + var isMinimized: Bool? { + guard isWindow else { return nil } + return bool(for: kAXMinimizedAttribute) + } + + /// Minimizes or unminimizes the window + @discardableResult + func setMinimized(_ minimized: Bool) -> AXError { + guard isWindow else { return .attributeUnsupported } + return setBool(kAXMinimizedAttribute, value: minimized) + } + + /// Gets the close button of the window + var closeButton: AXElement? { + guard isWindow else { return nil } + return element(for: kAXCloseButtonAttribute) + } + + /// Gets the minimize button of the window + var minimizeButton: AXElement? { + guard isWindow else { return nil } + return element(for: kAXMinimizeButtonAttribute) + } + + /// Gets the main window state + var isMain: Bool? { + guard isWindow else { return nil } + return bool(for: kAXMainAttribute) + } + + /// Sets the main window state + @discardableResult + func setMain(_ main: Bool) -> AXError { + guard isWindow else { return .attributeUnsupported } + return setBool(kAXMainAttribute, value: main) + } + + /// Gets the focused window state + var isFocusedWindow: Bool? { + guard isWindow else { return nil } + return bool(for: kAXFocusedAttribute) + } + + /// Sets the focused window state + @discardableResult + func setFocused(_ focused: Bool) -> AXError { + guard isWindow else { return .attributeUnsupported } + return setBool(kAXFocusedAttribute, value: focused) + } +} + +// MARK: - Tab Operations + +public extension AXElement { + /// Gets tabs from a tab group or window + var tabs: [AXElement]? { + // First try the direct tabs attribute + if let tabs = elements(for: kAXTabsAttribute) { + return tabs + } + + // For tab groups, try the AXTabs attribute + if let tabs = elements(for: "AXTabs") { + return tabs + } + + return nil + } + + /// Checks if this element is selected (for tabs) + var isSelected: Bool? { + bool(for: kAXSelectedAttribute) + } + + /// Sets the selected state + @discardableResult + func setSelected(_ selected: Bool) -> AXError { + setBool(kAXSelectedAttribute, value: selected) + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Accessibility/AXPermissions.swift b/mac/VibeTunnel/Core/Accessibility/AXPermissions.swift new file mode 100644 index 00000000..c14944f9 --- /dev/null +++ b/mac/VibeTunnel/Core/Accessibility/AXPermissions.swift @@ -0,0 +1,154 @@ +import ApplicationServices +import AppKit +import Foundation +import OSLog + +/// Utilities for managing macOS accessibility permissions. +/// Provides convenient methods for checking and requesting accessibility permissions. +public enum AXPermissions { + private static let logger = Logger( + subsystem: "sh.vibetunnel.vibetunnel", + category: "AXPermissions" + ) + + /// Checks if the app currently has accessibility permissions without prompting. + public static var hasPermissions: Bool { + AXIsProcessTrusted() + } + + /// Requests accessibility permissions, showing the system prompt if needed. + /// - Returns: `true` if permissions are granted, `false` otherwise + @MainActor + public static func requestPermissions() -> Bool { + // Skip permission dialog in test environment + if isTestEnvironment { + logger.debug("Skipping permission request in test environment") + return false + } + + // Use direct API without options to avoid concurrency issues + let trusted = AXIsProcessTrusted() + if !trusted { + // Open accessibility preferences to prompt user + openAccessibilityPreferences() + } + + logger.info("Accessibility permissions checked, trusted: \(trusted)") + return trusted + } + + /// Determines if the app is running in a test environment + private static var isTestEnvironment: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil || + ProcessInfo.processInfo.arguments.contains("--test-mode") || + NSClassFromString("XCTest") != nil + } + + /// Determines if the app is running in a sandboxed environment + public static var isSandboxed: Bool { + ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil + } + + /// Opens System Preferences to the Security & Privacy > Accessibility pane + @MainActor + public static func openAccessibilityPreferences() { + logger.info("Opening Accessibility preferences") + + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + /// Monitors accessibility permission changes asynchronously + /// - Parameter interval: The polling interval in seconds (default: 1.0) + /// - Returns: An AsyncStream that emits permission status changes + public static func permissionChanges(interval: TimeInterval = 1.0) -> AsyncStream { + AsyncStream { continuation in + let initialState = hasPermissions + continuation.yield(initialState) + + // Timer holder to avoid capture issues + final class TimerHolder: @unchecked Sendable { + var timer: Timer? + var lastState: Bool + + init(initialState: Bool) { + self.lastState = initialState + } + + deinit { + timer?.invalidate() + } + } + + let holder = TimerHolder(initialState: initialState) + + holder.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + let currentState = hasPermissions + if currentState != holder.lastState { + holder.lastState = currentState + continuation.yield(currentState) + logger.info("Accessibility permission changed to: \(currentState)") + } + } + + continuation.onTermination = { @Sendable _ in + DispatchQueue.main.async { + holder.timer?.invalidate() + holder.timer = nil + } + } + } + } + + /// Requests permissions asynchronously + /// - Returns: `true` if permissions are granted, `false` otherwise + @MainActor + public static func requestPermissionsAsync() async -> Bool { + return requestPermissions() + } +} + +// MARK: - Convenience Extensions + +public extension AXPermissions { + /// Ensures accessibility permissions are granted, prompting if necessary + /// - Parameter onDenied: Closure to execute if permissions are denied + /// - Returns: `true` if permissions are granted, `false` otherwise + @MainActor + static func ensurePermissions(onDenied: (() -> Void)? = nil) -> Bool { + if hasPermissions { + return true + } + + let granted = requestPermissions() + if !granted { + logger.warning("Accessibility permissions denied") + onDenied?() + } + + return granted + } + + /// Checks permissions and shows an alert if not granted + @MainActor + static func checkPermissionsWithAlert() -> Bool { + if hasPermissions { + return true + } + + let alert = NSAlert() + alert.messageText = "Accessibility Permission Required" + alert.informativeText = "VibeTunnel needs accessibility permissions to interact with terminal windows. Please grant access in System Preferences." + alert.alertStyle = .warning + alert.addButton(withTitle: "Open System Preferences") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + openAccessibilityPreferences() + } + + return false + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Services/SessionService.swift b/mac/VibeTunnel/Core/Services/SessionService.swift index b482cba3..4ba88bc2 100644 --- a/mac/VibeTunnel/Core/Services/SessionService.swift +++ b/mac/VibeTunnel/Core/Services/SessionService.swift @@ -50,6 +50,21 @@ final class SessionService { } /// Terminate a session + /// + /// This method performs a two-step termination process: + /// 1. Sends a DELETE request to the server to kill the process + /// 2. Closes the terminal window if it was opened by VibeTunnel + /// + /// The window closing step is crucial for user experience - it prevents + /// the accumulation of empty terminal windows after killing processes. + /// However, it only closes windows that VibeTunnel opened via AppleScript, + /// not windows from external `vt` attachments. + /// + /// - Parameter sessionId: The ID of the session to terminate + /// - Throws: `SessionServiceError` if the termination request fails + /// + /// - Note: The server implements graceful termination (SIGTERM → SIGKILL) + /// with a 3-second timeout before force-killing processes. func terminateSession(sessionId: String) async throws { guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else { throw SessionServiceError.invalidURL @@ -68,9 +83,79 @@ final class SessionService { throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1) } + // After successfully terminating the session, close the window if we opened it. + // This is the key feature that prevents orphaned terminal windows. + // + // Why this matters: + // - Simple commands (like `ls`) exit naturally and close their windows + // - Long-running processes (like `claude`) leave windows open when killed + // - This ensures consistent behavior - windows always close when sessions end + // + // The check inside closeWindowIfOpenedByUs ensures we only close windows + // that VibeTunnel created, not externally attached sessions. + _ = await MainActor.run { + WindowTracker.shared.closeWindowIfOpenedByUs(for: sessionId) + } + // The session monitor will automatically update via its polling mechanism } + /// Send input text to a session + func sendInput(to sessionId: String, text: String) async throws { + guard serverManager.isRunning else { + throw SessionServiceError.serverNotRunning + } + + guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else { + throw SessionServiceError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("localhost", forHTTPHeaderField: "Host") + try serverManager.authenticate(request: &request) + + let body = ["text": text] + request.httpBody = try JSONEncoder().encode(body) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 || httpResponse.statusCode == 204 + else { + throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1) + } + } + + /// Send a key command to a session + func sendKey(to sessionId: String, key: String) async throws { + guard serverManager.isRunning else { + throw SessionServiceError.serverNotRunning + } + + guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else { + throw SessionServiceError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("localhost", forHTTPHeaderField: "Host") + try serverManager.authenticate(request: &request) + + let body = ["key": key] + request.httpBody = try JSONEncoder().encode(body) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 || httpResponse.statusCode == 204 + else { + throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1) + } + } + /// Create a new session func createSession( command: [String], diff --git a/mac/VibeTunnel/Core/Services/WindowTracker.swift b/mac/VibeTunnel/Core/Services/WindowTracker.swift index 432a4de3..1576597a 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracker.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracker.swift @@ -9,6 +9,29 @@ import OSLog /// - Map VibeTunnel sessions to their terminal windows /// - Focus specific terminal windows when requested /// - Handle both windows and tabs for different terminal applications +/// - **Close terminal windows when sessions are terminated (NEW)** +/// +/// ## Window Closing Feature +/// +/// A key enhancement is the ability to automatically close terminal windows when +/// their associated sessions are terminated. This solves the common problem where +/// killing a long-running process (like `claude`) leaves an empty terminal window. +/// +/// ### Design Principles: +/// 1. **Only close what we open**: Windows are only closed if VibeTunnel opened them +/// 2. **Track ownership at creation**: Sessions opened via AppleScript are marked at launch time +/// 3. **Respect external sessions**: Sessions attached via `vt` are never closed +/// +/// ### Implementation: +/// - When spawning terminals via AppleScript, sessions are marked in `sessionsOpenedByUs` set +/// - On termination, we dynamically find windows using process tree traversal +/// - Only windows for sessions in the set are closed +/// - Currently supports Terminal.app and iTerm2 +/// +/// ### User Experience: +/// - Consistent behavior: All VibeTunnel-spawned windows close on termination +/// - No orphaned windows: Prevents accumulation of empty terminals +/// - External sessions preserved: `vt`-attached terminals remain open @MainActor final class WindowTracker { static let shared = WindowTracker() @@ -21,6 +44,24 @@ final class WindowTracker { /// Maps session IDs to their terminal window information private var sessionWindowMap: [String: WindowEnumerator.WindowInfo] = [:] + /// Tracks which sessions we opened via AppleScript (and can close). + /// + /// When VibeTunnel spawns a terminal session through AppleScript, we mark + /// it in this set. This allows us to distinguish between: + /// - Sessions we created: Can and should close their windows + /// - Sessions attached via `vt`: Should never close their windows + /// + /// The actual window finding happens dynamically using process tree traversal, + /// making the system robust against tab reordering and window manipulation. + /// + /// Example flow: + /// 1. User creates session via UI → TerminalLauncher uses AppleScript + /// 2. Session ID is added to this set + /// 3. User kills session → We find and close the window dynamically + /// + /// Sessions attached via `vt` command are NOT added to this set. + private var sessionsOpenedByUs: Set = [] + /// Lock for thread-safe access to the session map private let mapLock = NSLock() @@ -37,70 +78,37 @@ final class WindowTracker { // MARK: - Window Registration - /// Registers a terminal window for a session. + /// Registers a session that was opened by VibeTunnel. /// This should be called after launching a terminal with a session ID. + /// Only sessions registered here will have their windows closed on termination. + func registerSessionOpenedByUs( + for sessionID: String, + terminalApp: Terminal + ) { + logger.info("Registering session opened by us: \(sessionID), terminal: \(terminalApp.rawValue)") + + // Mark this session as opened by us, so we can close its window later + // This is the critical point where we distinguish between: + // - Sessions we created via AppleScript (can close) + // - Sessions attached via `vt` command (cannot close) + _ = mapLock.withLock { + sessionsOpenedByUs.insert(sessionID) + } + + // Window finding is now handled dynamically when needed (focus/close) + // This avoids storing stale tab references + } + + /// Legacy method for compatibility - redirects to simplified registration func registerWindow( for sessionID: String, terminalApp: Terminal, tabReference: String? = nil, tabID: String? = nil ) { - logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)") - - // For Terminal.app and iTerm2 with explicit window/tab info, register immediately - if (terminalApp == .terminal && tabReference != nil) || - (terminalApp == .iTerm2 && tabID != nil) - { - // These terminals provide explicit window/tab IDs, so we can register immediately - Task { - try? await Task.sleep(for: .milliseconds(500)) - - if let windowInfo = findWindow( - for: terminalApp, - sessionID: sessionID, - tabReference: tabReference, - tabID: tabID - ) { - mapLock.withLock { - sessionWindowMap[sessionID] = windowInfo - } - logger - .info( - "Successfully registered window \(windowInfo.windowID) for session \(sessionID) with explicit ID" - ) - } - } - return - } - - // For other terminals, use progressive delays to find the window - Task { - // Try multiple times with increasing delays - let delays: [Double] = [0.5, 1.0, 2.0, 3.0] - - for (index, delay) in delays.enumerated() { - try? await Task.sleep(for: .seconds(delay)) - - // Try to find the window - if let windowInfo = findWindow( - for: terminalApp, - sessionID: sessionID, - tabReference: tabReference, - tabID: tabID - ) { - mapLock.withLock { - sessionWindowMap[sessionID] = windowInfo - } - logger - .info( - "Successfully registered window \(windowInfo.windowID) for session \(sessionID) after \(index + 1) attempts" - ) - return - } - } - - logger.warning("Failed to register window for session \(sessionID) after all attempts") - } + // Simply mark the session as opened by us + // We no longer store tab references as they become stale + registerSessionOpenedByUs(for: sessionID, terminalApp: terminalApp) } /// Unregisters a window for a session. @@ -109,6 +117,7 @@ final class WindowTracker { if sessionWindowMap.removeValue(forKey: sessionID) != nil { logger.info("Unregistered window for session: \(sessionID)") } + sessionsOpenedByUs.remove(sessionID) } } @@ -148,6 +157,153 @@ final class WindowTracker { windowFocuser.focusWindow(windowInfo) } + // MARK: - Window Closing + + /// Closes the terminal window for a specific session if it was opened by VibeTunnel. + /// + /// This method implements a key feature where terminal windows are automatically closed + /// when their associated sessions are terminated, but ONLY if VibeTunnel opened them. + /// This prevents the common issue where killing a process leaves empty terminal windows. + /// + /// The method checks if: + /// 1. The session was opened by VibeTunnel (exists in `sessionsOpenedByUs`) + /// 2. We can find the window using dynamic lookup (process tree traversal) + /// 3. We can close via Accessibility API (PID-based) or AppleScript + /// + /// - Parameter sessionID: The ID of the session whose window should be closed + /// - Returns: `true` if the window was successfully closed, `false` otherwise + /// + /// - Note: This is called automatically by `SessionService.terminateSession()` + /// after the server confirms the process has been killed. + /// + /// Example scenarios: + /// - ✅ User runs `claude` command via UI → Window closes when session killed + /// - ✅ User runs long process via UI → Window closes when session killed + /// - ❌ User attaches existing terminal via `vt` → Window NOT closed + /// - ❌ User manually opens terminal → Window NOT closed + @discardableResult + func closeWindowIfOpenedByUs(for sessionID: String) -> Bool { + // Check if we opened this window + let wasOpenedByUs = mapLock.withLock { + sessionsOpenedByUs.contains(sessionID) + } + + guard wasOpenedByUs else { + logger.info("Session \(sessionID) was not opened by VibeTunnel, not closing window") + return false + } + + // Use dynamic lookup to find the window + // This is more reliable than stored references which can become stale + guard let sessionInfo = getSessionInfo(for: sessionID) else { + logger.warning("No session info found for session: \(sessionID)") + unregisterWindow(for: sessionID) + return false + } + + guard let windowInfo = findWindowForSession(sessionID, sessionInfo: sessionInfo) else { + logger.warning("Could not find window for session \(sessionID) - it may have been closed already") + // Clean up tracking since window is gone + unregisterWindow(for: sessionID) + return false + } + + logger.info("Closing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue)") + + // Generate and execute AppleScript to close the window + let closeScript = generateCloseWindowScript(for: windowInfo) + do { + try AppleScriptExecutor.shared.execute(closeScript) + logger.info("Successfully closed window for session: \(sessionID)") + + // Clean up tracking + unregisterWindow(for: sessionID) + return true + } catch { + logger.error("Failed to close window for session \(sessionID): \(error)") + return false + } + } + + /// Generates AppleScript to close a specific terminal window. + /// + /// This method creates terminal-specific AppleScript commands to close windows. + /// Uses window IDs from dynamic lookup rather than stored tab references, + /// making it robust against tab reordering and window manipulation. + /// + /// - **Terminal.app**: Uses window ID to close the entire window + /// - `saving no` prevents save dialogs + /// - Closes all tabs in the window + /// + /// - **iTerm2**: Uses window ID with robust matching + /// - Iterates through windows to find exact match + /// - Closes entire window + /// + /// - **Ghostty**: Uses standard AppleScript window closing + /// - Directly closes window by ID + /// - Supports modern window management + /// + /// - **Other terminals**: Not supported as they don't provide reliable window IDs + /// + /// - Parameter windowInfo: Window information from dynamic lookup + /// - Returns: AppleScript string to close the window, or empty string if unsupported + /// + /// - Note: All scripts include error handling to gracefully handle already-closed windows + private func generateCloseWindowScript(for windowInfo: WindowEnumerator.WindowInfo) -> String { + switch windowInfo.terminalApp { + case .terminal: + // Use window ID to close - more reliable than tab references + return """ + tell application "Terminal" + try + close (first window whose id is \(windowInfo.windowID)) saving no + on error + -- Window might already be closed + end try + end tell + """ + + case .iTerm2: + // For iTerm2, close the window by matching against all windows + // iTerm2's window IDs can be tricky, so we use a more robust approach + return """ + tell application "iTerm2" + try + set targetWindows to (windows) + repeat with w in targetWindows + try + if id of w is \(windowInfo.windowID) then + close w + exit repeat + end if + end try + end repeat + on error + -- Window might already be closed + end try + end tell + """ + + case .ghostty: + // Ghostty supports standard AppleScript window operations + // Note: Ghostty uses lowercase "ghostty" in System Events + return """ + tell application "ghostty" + try + close (first window whose id is \(windowInfo.windowID)) + on error + -- Window might already be closed + end try + end tell + """ + + default: + // For other terminals, we don't have reliable window closing + logger.warning("Cannot close window for \(windowInfo.terminalApp.rawValue) - terminal not supported") + return "" + } + } + // MARK: - Permission Management /// Check if we have the required permissions. @@ -178,6 +334,17 @@ final class WindowTracker { if sessionWindowMap.removeValue(forKey: sessionID) != nil { logger.info("Removed window tracking for terminated session: \(sessionID)") } + // Also clean up the opened-by-us tracking + sessionsOpenedByUs.remove(sessionID) + } + } + + // Check for sessions that have exited and close their windows if we opened them + for session in sessions where session.status == "exited" { + // Only close windows that we opened (not external vt attachments) + if sessionsOpenedByUs.contains(session.id) { + logger.info("Session \(session.id) has exited naturally, closing its window") + _ = closeWindowIfOpenedByUs(for: session.id) } } diff --git a/mac/VibeTunnel/Core/Services/WindowTracking/WindowFocuser.swift b/mac/VibeTunnel/Core/Services/WindowTracking/WindowFocuser.swift index d8bc2465..320db043 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracking/WindowFocuser.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracking/WindowFocuser.swift @@ -11,6 +11,72 @@ final class WindowFocuser { ) private let windowMatcher = WindowMatcher() + private let highlightEffect: WindowHighlightEffect + + init() { + // Load configuration from UserDefaults + let config = Self.loadHighlightConfig() + self.highlightEffect = WindowHighlightEffect(config: config) + + // Observe UserDefaults changes + NotificationCenter.default.addObserver( + self, + selector: #selector(userDefaultsDidChange), + name: UserDefaults.didChangeNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Load highlight configuration from UserDefaults + private static func loadHighlightConfig() -> WindowHighlightConfig { + let defaults = UserDefaults.standard + let isEnabled = defaults.object(forKey: "windowHighlightEnabled") as? Bool ?? true + let style = defaults.string(forKey: "windowHighlightStyle") ?? "default" + + guard isEnabled else { + return WindowHighlightConfig( + color: .clear, + duration: 0, + borderWidth: 0, + glowRadius: 0, + isEnabled: false + ) + } + + switch style { + case "subtle": + return .subtle + case "neon": + return .neon + case "custom": + // Load custom color + let colorData = defaults.data(forKey: "windowHighlightColor") ?? Data() + if !colorData.isEmpty, + let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + return WindowHighlightConfig( + color: nsColor, + duration: 0.8, + borderWidth: 4.0, + glowRadius: 12.0, + isEnabled: true + ) + } + return .default + default: + return .default + } + } + + /// Handle UserDefaults changes + @objc private func userDefaultsDidChange(_ notification: Notification) { + // Update highlight configuration when settings change + let newConfig = Self.loadHighlightConfig() + highlightEffect.updateConfig(newConfig) + } /// Focus a window based on terminal type func focusWindow(_ windowInfo: WindowEnumerator.WindowInfo) { @@ -129,29 +195,20 @@ final class WindowFocuser { } /// Get the first tab group in a window (improved approach based on screenshot) - private func getTabGroup(from window: AXUIElement) -> AXUIElement? { - var childrenRef: CFTypeRef? - guard AXUIElementCopyAttributeValue( - window, - kAXChildrenAttribute as CFString, - &childrenRef - ) == .success, - let children = childrenRef as? [AXUIElement] - else { + private func getTabGroup(from window: AXElement) -> AXElement? { + guard let children = window.children else { return nil } - + // Find the first element with role kAXTabGroupRole return children.first { elem in - var roleRef: CFTypeRef? - AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef) - return (roleRef as? String) == kAXTabGroupRole as String + elem.role == kAXTabGroupRole } } /// Select the correct tab in a window that uses macOS standard tabs private func selectTab( - tabs: [AXUIElement], + tabs: [AXElement], windowInfo: WindowEnumerator.WindowInfo, sessionInfo: ServerSessionInfo? ) { @@ -160,22 +217,14 @@ final class WindowFocuser { // Try to find the correct tab if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) { // Found matching tab - select it using kAXPressAction (most reliable) - let result = AXUIElementPerformAction(matchingTab, kAXPressAction as CFString) - if result == .success { + if matchingTab.press() { logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)") } else { - logger.warning("Failed to select tab with kAXPressAction, error: \(result.rawValue)") + logger.warning("Failed to select tab with kAXPressAction") // Try alternative selection method - set as selected - var selectedValue: CFTypeRef? - if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) == - .success - { - let setResult = AXUIElementSetAttributeValue( - matchingTab, - kAXSelectedAttribute as CFString, - true as CFTypeRef - ) + if matchingTab.isAttributeSettable(kAXSelectedAttribute) { + let setResult = matchingTab.setSelected(true) if setResult == .success { logger.info("Selected tab using AXSelected attribute") } else { @@ -185,7 +234,7 @@ final class WindowFocuser { } } else if tabs.count == 1 { // If only one tab, select it - AXUIElementPerformAction(tabs[0], kAXPressAction as CFString) + tabs[0].press() logger.info("Selected the only available tab") } else { // Multiple tabs but no match - try to find by index or select first @@ -196,10 +245,7 @@ final class WindowFocuser { // Log tab titles for debugging for (index, tab) in tabs.enumerated() { - var titleValue: CFTypeRef? - if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, - let title = titleValue as? String - { + if let title = tab.title { logger.debug(" Tab \(index): \(title)") } } @@ -207,73 +253,255 @@ final class WindowFocuser { } /// Select a tab by index in a tab group (helper method from screenshot) - private func selectTab(at index: Int, in group: AXUIElement) -> Bool { - var tabsRef: CFTypeRef? - guard AXUIElementCopyAttributeValue( - group, - "AXTabs" as CFString, - &tabsRef - ) == .success, - let tabs = tabsRef as? [AXUIElement], - index < tabs.count + private func selectTab(at index: Int, in group: AXElement) -> Bool { + guard let tabs = group.tabs, + index < tabs.count else { logger.warning("Could not get tabs from group or index out of bounds") return false } + + return tabs[index].press() + } - let result = AXUIElementPerformAction(tabs[index], kAXPressAction as CFString) - return result == .success + /// Focuses a window by using the process PID directly + private func focusWindowUsingPID(_ windowInfo: WindowEnumerator.WindowInfo) -> Bool { + // Get session info for better matching + let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID] + // Create AXElement directly from the PID + let axProcess = AXElement.application(pid: windowInfo.ownerPID) + + // Get windows from this specific process + guard let windows = axProcess.windows, + !windows.isEmpty + else { + logger.debug("PID-based lookup failed for PID \(windowInfo.ownerPID), no windows found") + return false + } + + logger.info("Found \(windows.count) window(s) for PID \(windowInfo.ownerPID)") + + // Single window case - simple! + if windows.count == 1 { + logger.info("Single window found for PID \(windowInfo.ownerPID), focusing it directly") + let window = windows[0] + + // Show highlight effect + highlightEffect.highlightWindow(window, bounds: window.frame()) + + // Focus the window + window.setMain(true) + window.setFocused(true) + + // Bring app to front + if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { + app.activate() + } + + return true + } + + // Multiple windows - need to be smarter + logger.info("Multiple windows found for PID \(windowInfo.ownerPID), using scoring system") + + // Use our existing scoring logic but only on these PID-specific windows + var bestMatch: (window: AXElement, score: Int)? + + for (index, window) in windows.enumerated() { + var matchScore = 0 + + // Check window title for session ID or working directory (most reliable) + if let title = window.title { + logger.debug("Window \(index) title: '\(title)'") + + // Check for session ID in title + if title.contains(windowInfo.sessionID) || title.contains("TTY_SESSION_ID=\(windowInfo.sessionID)") { + matchScore += 200 // Highest score for session ID match + logger.debug("Window \(index) has session ID in title!") + } + + // Check for working directory in title + if let sessionInfo = sessionInfo { + let workingDir = sessionInfo.workingDir + let dirName = (workingDir as NSString).lastPathComponent + + if !dirName.isEmpty && (title.contains(dirName) || title.hasSuffix(dirName) || title.hasSuffix(" - \(dirName)")) { + matchScore += 100 // High score for directory match + logger.debug("Window \(index) has working directory in title: \(dirName)") + } + + // Check for session name + if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) { + matchScore += 150 // High score for session name match + logger.debug("Window \(index) has session name in title: \(sessionName)") + } + } + } + + // Check window ID (less reliable for terminals) + if let axWindowID = window.windowID { + if axWindowID == windowInfo.windowID { + matchScore += 50 // Lower score since window IDs can be unreliable + logger.debug("Window \(index) has matching ID: \(axWindowID)") + } + } + + // Check bounds if available (least reliable as windows can move) + if let bounds = windowInfo.bounds, + let windowFrame = window.frame() { + let tolerance: CGFloat = 5.0 + if abs(windowFrame.origin.x - bounds.origin.x) < tolerance && + abs(windowFrame.origin.y - bounds.origin.y) < tolerance && + abs(windowFrame.width - bounds.width) < tolerance && + abs(windowFrame.height - bounds.height) < tolerance + { + matchScore += 25 // Lowest score for bounds match + logger.debug("Window \(index) bounds match") + } + } + + if matchScore > 0 && (bestMatch == nil || matchScore > bestMatch!.score) { + bestMatch = (window, matchScore) + } + } + + if let best = bestMatch { + logger.info("Focusing best match window with score \(best.score) for PID \(windowInfo.ownerPID)") + + // Show highlight effect + highlightEffect.highlightWindow(best.window, bounds: best.window.frame()) + + // Focus the window + best.window.setMain(true) + best.window.setFocused(true) + + // Bring app to front + if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { + app.activate() + } + + return true + } + + logger.error("No matching window found for PID \(windowInfo.ownerPID)") + return false } /// Focuses a window using Accessibility APIs. private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) { + // First try PID-based approach + if focusWindowUsingPID(windowInfo) { + logger.info("Successfully focused window using PID-based approach") + return + } + + // Fallback to the original approach if PID-based fails + logger.info("Falling back to terminal app-based window search") + // First bring the application to front if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { app.activate() logger.info("Activated application with PID: \(windowInfo.ownerPID)") } - // Use AXUIElement to focus the specific window - let axApp = AXUIElementCreateApplication(windowInfo.ownerPID) + // Use AXElement to focus the specific window + let axApp = AXElement.application(pid: windowInfo.ownerPID) - var windowsValue: CFTypeRef? - let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue) - - guard result == .success, - let windows = windowsValue as? [AXUIElement], + guard let windows = axApp.windows, !windows.isEmpty else { logger.error("Failed to get windows for application") return } - logger.debug("Found \(windows.count) windows for \(windowInfo.terminalApp.rawValue)") + logger + .info( + "Found \(windows.count) windows for \(windowInfo.terminalApp.rawValue), looking for window ID: \(windowInfo.windowID)" + ) // Get session info for tab matching let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID] // First, try to find window with matching tab content - var foundWindowWithTab = false + var bestMatchWindow: (window: AXElement, score: Int)? for (index, window) in windows.enumerated() { - // Check different window ID attributes (different apps use different ones) + var matchScore = 0 var windowMatches = false - // Try _AXWindowNumber (used by many apps) - var windowIDValue: CFTypeRef? - if AXUIElementCopyAttributeValue(window, "_AXWindowNumber" as CFString, &windowIDValue) == .success, - let axWindowID = windowIDValue as? Int + // Try window ID attribute for matching + if let axWindowID = window.windowID { + if axWindowID == windowInfo.windowID { + windowMatches = true + matchScore += 100 // High score for exact ID match + } + logger + .debug( + "Window \(index) windowID: \(axWindowID), target: \(windowInfo.windowID), matches: \(windowMatches)" + ) + } + + // Check window position and size as secondary validation + if let bounds = windowInfo.bounds, + let windowFrame = window.frame() { - windowMatches = (axWindowID == windowInfo.windowID) - logger.debug("Window \(index) _AXWindowNumber: \(axWindowID), matches: \(windowMatches)") + // Check if bounds approximately match (within 5 pixels tolerance) + let tolerance: CGFloat = 5.0 + if abs(windowFrame.origin.x - bounds.origin.x) < tolerance && + abs(windowFrame.origin.y - bounds.origin.y) < tolerance && + abs(windowFrame.width - bounds.width) < tolerance && + abs(windowFrame.height - bounds.height) < tolerance + { + matchScore += 50 // Medium score for bounds match + logger + .debug( + "Window \(index) bounds match! Position: (\(windowFrame.origin.x), \(windowFrame.origin.y)), Size: (\(windowFrame.width), \(windowFrame.height))" + ) + } + } + + // Check window title for session information + if let title = window.title { + logger.debug("Window \(index) title: '\(title)'") + + // Check for session ID in title (most reliable) + if title.contains(windowInfo.sessionID) || title.contains("TTY_SESSION_ID=\(windowInfo.sessionID)") { + matchScore += 200 // Highest score + logger.debug("Window \(index) has session ID in title!") + } + + // Check for session-specific information + if let sessionInfo = sessionInfo { + let workingDir = sessionInfo.workingDir + let dirName = (workingDir as NSString).lastPathComponent + + if !dirName.isEmpty && (title.contains(dirName) || title.hasSuffix(dirName)) { + matchScore += 100 + logger.debug("Window \(index) has working directory in title") + } + + if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) { + matchScore += 150 + logger.debug("Window \(index) has session name in title") + } + } + + // Original title match logic as fallback + if !title.isEmpty && (windowInfo.title?.contains(title) ?? false || title.contains(windowInfo.title ?? "")) { + matchScore += 25 // Low score for title match + } + } + + // Keep track of best match + if matchScore > 0 && (bestMatchWindow == nil || matchScore > bestMatchWindow!.score) { + bestMatchWindow = (window, matchScore) + logger.debug("Window \(index) is new best match with score: \(matchScore)") } // Try the improved approach: get tab group first if let tabGroup = getTabGroup(from: window) { // Get tabs from the tab group - var tabsValue: CFTypeRef? - if AXUIElementCopyAttributeValue(tabGroup, "AXTabs" as CFString, &tabsValue) == .success, - let tabs = tabsValue as? [AXUIElement], + if let tabs = tabGroup.tabs, !tabs.isEmpty { logger.info("Window \(index) has tab group with \(tabs.count) tabs") @@ -283,24 +511,22 @@ final class WindowFocuser { // Found the tab! Focus the window and select the tab logger.info("Found matching tab in window \(index)") + // Show highlight effect + highlightEffect.highlightWindow(window, bounds: window.frame()) + // Make window main and focused - AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) - AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) + window.setMain(true) + window.setFocused(true) // Select the tab selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) - foundWindowWithTab = true return } } } else { // Fallback: Try direct tabs attribute (older approach) - var tabsValue: CFTypeRef? - let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) - - if hasTabsResult == .success, - let tabs = tabsValue as? [AXUIElement], + if let tabs = window.tabs, !tabs.isEmpty { logger.info("Window \(index) has \(tabs.count) tabs (direct attribute)") @@ -310,36 +536,59 @@ final class WindowFocuser { // Found the tab! Focus the window and select the tab logger.info("Found matching tab in window \(index)") + // Show highlight effect + highlightEffect.highlightWindow(window, bounds: window.frame()) + // Make window main and focused - AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) - AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) + window.setMain(true) + window.setFocused(true) // Select the tab selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) - foundWindowWithTab = true return } - } else if windowMatches { - // Window matches by ID but has no tabs (or tabs not accessible) - logger.info("Window \(index) matches by ID but has no accessible tabs") - - // Focus the window anyway - AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) - AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) - - logger.info("Focused window \(windowInfo.windowID) without tab selection") - return } } } - // If we didn't find a window with matching tab, just focus the first window - if !foundWindowWithTab && !windows.isEmpty { - logger.warning("No window found with matching tab, focusing first window") - let firstWindow = windows[0] - AXUIElementSetAttributeValue(firstWindow, kAXMainAttribute as CFString, true as CFTypeRef) - AXUIElementSetAttributeValue(firstWindow, kAXFocusedAttribute as CFString, true as CFTypeRef) + // After checking all windows, use the best match if we found one + if let bestMatch = bestMatchWindow { + logger.info("Using best match window with score \(bestMatch.score) for window ID \(windowInfo.windowID)") + + // Show highlight effect + highlightEffect.highlightWindow(bestMatch.window, bounds: bestMatch.window.frame()) + + // Focus the best matching window + bestMatch.window.setMain(true) + bestMatch.window.setFocused(true) + + // Try to select tab if available + if sessionInfo != nil { + // Try to get tabs and select the right one + if let tabGroup = getTabGroup(from: bestMatch.window) { + if let tabs = tabGroup.tabs, + !tabs.isEmpty + { + selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) + } + } else { + // Try direct tabs attribute + if let tabs = bestMatch.window.tabs, + !tabs.isEmpty + { + selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) + } + } + } + + logger.info("Focused best match window for session \(windowInfo.sessionID)") + } else { + // No match found at all - log error but don't focus random window + logger + .error( + "Failed to find window with ID \(windowInfo.windowID) for session \(windowInfo.sessionID). No windows matched by ID, position, or title." + ) } } } diff --git a/mac/VibeTunnel/Core/Services/WindowTracking/WindowHighlightEffect.swift b/mac/VibeTunnel/Core/Services/WindowTracking/WindowHighlightEffect.swift new file mode 100644 index 00000000..308745b1 --- /dev/null +++ b/mac/VibeTunnel/Core/Services/WindowTracking/WindowHighlightEffect.swift @@ -0,0 +1,297 @@ +import AppKit +import Foundation +import OSLog + +/// Configuration for window highlight effects +struct WindowHighlightConfig { + /// The color of the highlight border + let color: NSColor + + /// Duration of the pulse animation in seconds + let duration: TimeInterval + + /// Width of the border stroke + let borderWidth: CGFloat + + /// Radius of the glow effect + let glowRadius: CGFloat + + /// Whether the effect is enabled + let isEnabled: Bool + + /// Default configuration with VibeTunnel branding + static let `default` = WindowHighlightConfig( + color: NSColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0), // Green to match frontend + duration: 0.8, + borderWidth: 4.0, + glowRadius: 12.0, + isEnabled: true + ) + + /// A more subtle configuration + static let subtle = WindowHighlightConfig( + color: .systemBlue, + duration: 0.5, + borderWidth: 2.0, + glowRadius: 6.0, + isEnabled: true + ) + + /// A vibrant neon-style configuration + static let neon = WindowHighlightConfig( + color: NSColor(red: 0.0, green: 1.0, blue: 0.8, alpha: 1.0), // Cyan + duration: 1.2, + borderWidth: 6.0, + glowRadius: 20.0, + isEnabled: true + ) +} + +/// Provides visual highlighting effects for terminal windows. +/// Creates a border pulse/glow effect to make window selection more noticeable. +@MainActor +final class WindowHighlightEffect { + private let logger = Logger( + subsystem: "sh.vibetunnel.vibetunnel", + category: "WindowHighlightEffect" + ) + + /// Active overlay windows for effects + private var overlayWindows: [NSWindow] = [] + + /// Current configuration + private var config: WindowHighlightConfig = .default + + /// Initialize with a specific configuration + init(config: WindowHighlightConfig = .default) { + self.config = config + } + + /// Update the configuration + func updateConfig(_ newConfig: WindowHighlightConfig) { + self.config = newConfig + } + + /// Converts screen coordinates (top-left origin) to Cocoa coordinates (bottom-left origin) + /// This is necessary because: + /// - Accessibility API returns coordinates with origin at screen top-left + /// - NSWindow expects coordinates with origin at screen bottom-left + /// - Multiple displays complicate this further + private func convertScreenToCocoaCoordinates(_ screenFrame: CGRect) -> CGRect { + // The key insight: NSScreen coordinates are ALREADY in Cocoa coordinates (bottom-left origin) + // But the window bounds we get from Accessibility API are in screen coordinates (top-left origin) + + // First, we need to find the total screen height across all displays + let screens = NSScreen.screens + guard let mainScreen = NSScreen.main else { + logger.error("No main screen found") + return screenFrame + } + + // Find which screen contains this window by checking in screen coordinates + var targetScreen: NSScreen? + let windowCenter = CGPoint(x: screenFrame.midX, y: screenFrame.midY) + + for screen in screens { + // Convert screen's Cocoa frame to screen coordinates for comparison + let screenFrameInScreenCoords = convertCocoaToScreenRect(screen.frame, mainScreenHeight: mainScreen.frame.height) + + if screenFrameInScreenCoords.contains(windowCenter) { + targetScreen = screen + break + } + } + + // Use the screen we found, or main screen as fallback + let screen = targetScreen ?? mainScreen + + logger.debug("Screen info for coordinate conversion:") + logger.debug(" Target screen frame (Cocoa): x=\(screen.frame.origin.x), y=\(screen.frame.origin.y), w=\(screen.frame.width), h=\(screen.frame.height)") + logger.debug(" Window frame (screen coords): x=\(screenFrame.origin.x), y=\(screenFrame.origin.y), w=\(screenFrame.width), h=\(screenFrame.height)") + logger.debug(" Window center: x=\(windowCenter.x), y=\(windowCenter.y)") + logger.debug(" Is main screen: \(screen == NSScreen.main)") + + // Convert window coordinates from screen (top-left) to Cocoa (bottom-left) + // The key is that we need to use the main screen's height as reference + let mainScreenHeight = mainScreen.frame.height + + // In screen coordinates, y=0 is at the top of the main screen + // In Cocoa coordinates, y=0 is at the bottom of the main screen + // So: cocoaY = mainScreenHeight - (screenY + windowHeight) + let cocoaY = mainScreenHeight - (screenFrame.origin.y + screenFrame.height) + + return CGRect( + x: screenFrame.origin.x, + y: cocoaY, + width: screenFrame.width, + height: screenFrame.height + ) + } + + /// Helper to convert Cocoa rect to screen coordinates for comparison + private func convertCocoaToScreenRect(_ cocoaRect: CGRect, mainScreenHeight: CGFloat) -> CGRect { + // Convert from bottom-left origin to top-left origin + let screenY = mainScreenHeight - (cocoaRect.origin.y + cocoaRect.height) + return CGRect( + x: cocoaRect.origin.x, + y: screenY, + width: cocoaRect.width, + height: cocoaRect.height + ) + } + + /// Highlight a window with a border pulse effect + func highlightWindow(_ window: AXElement, bounds: CGRect? = nil) { + guard config.isEnabled else { return } + + let windowFrame: CGRect + + if let bounds = bounds { + // Use provided bounds + windowFrame = bounds + } else { + // Get window bounds using AXElement + guard let frame = window.frame() else { + logger.error("Failed to get window bounds for highlight effect") + return + } + windowFrame = frame + } + + // Convert from screen coordinates (top-left origin) to Cocoa coordinates (bottom-left origin) + let cocoaFrame = convertScreenToCocoaCoordinates(windowFrame) + + logger.debug("Window highlight coordinate conversion:") + logger.debug(" Original frame: x=\(windowFrame.origin.x), y=\(windowFrame.origin.y), w=\(windowFrame.width), h=\(windowFrame.height)") + logger.debug(" Cocoa frame: x=\(cocoaFrame.origin.x), y=\(cocoaFrame.origin.y), w=\(cocoaFrame.width), h=\(cocoaFrame.height)") + + // Create overlay window + let overlayWindow = createOverlayWindow( + frame: cocoaFrame + ) + + // Add to tracking + overlayWindows.append(overlayWindow) + + // Show the window + overlayWindow.orderFront(nil) + + // Animate the pulse effect + animatePulse(on: overlayWindow, duration: config.duration) { [weak self] in + Task { @MainActor in + self?.removeOverlay(overlayWindow) + } + } + } + + /// Create an overlay window for the effect + private func createOverlayWindow(frame: CGRect) -> NSWindow { + let window = NSWindow( + contentRect: frame, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + + window.backgroundColor = .clear + window.isOpaque = false + window.level = .screenSaver + window.ignoresMouseEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // Create custom view for the effect + let effectView = BorderEffectView( + frame: window.contentView!.bounds, + color: config.color, + borderWidth: config.borderWidth, + glowRadius: config.glowRadius + ) + effectView.autoresizingMask = [.width, .height] + window.contentView = effectView + + return window + } + + /// Animate the pulse effect + private func animatePulse(on window: NSWindow, duration: TimeInterval, completion: @escaping @Sendable () -> Void) { + guard let effectView = window.contentView as? BorderEffectView else { return } + + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + // Animate from full opacity to transparent + effectView.animator().alphaValue = 0.0 + } completionHandler: { + completion() + } + } + + /// Remove an overlay window + private func removeOverlay(_ window: NSWindow) { + window.orderOut(nil) + overlayWindows.removeAll { $0 == window } + } + + /// Clean up all overlay windows + func cleanup() { + for window in overlayWindows { + window.orderOut(nil) + } + overlayWindows.removeAll() + } +} + +/// Custom view for border effect +private class BorderEffectView: NSView { + private let borderColor: NSColor + private let borderWidth: CGFloat + private let glowRadius: CGFloat + + init(frame: NSRect, color: NSColor, borderWidth: CGFloat, glowRadius: CGFloat) { + self.borderColor = color + self.borderWidth = borderWidth + self.glowRadius = glowRadius + super.init(frame: frame) + self.wantsLayer = true + self.alphaValue = 1.0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + + // Create inset rect for border + let borderRect = bounds.insetBy(dx: borderWidth / 2, dy: borderWidth / 2) + let borderPath = NSBezierPath(roundedRect: borderRect, xRadius: 8, yRadius: 8) + + // Draw glow effect + context.setShadow( + offset: .zero, + blur: glowRadius, + color: borderColor.withAlphaComponent(0.8).cgColor + ) + + // Draw border + borderColor.setStroke() + borderPath.lineWidth = borderWidth + borderPath.stroke() + + // Draw inner glow + context.setShadow( + offset: .zero, + blur: glowRadius / 2, + color: borderColor.withAlphaComponent(0.4).cgColor + ) + borderPath.stroke() + + context.restoreGState() + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Services/WindowTracking/WindowMatcher.swift b/mac/VibeTunnel/Core/Services/WindowTracking/WindowMatcher.swift index d80eeec2..8a8d19a2 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracking/WindowMatcher.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracking/WindowMatcher.swift @@ -37,12 +37,28 @@ final class WindowMatcher { if let parentPID = processTracker.getParentProcessID(of: pid_t(sessionPID)) { logger.debug("Found parent process PID: \(parentPID)") - // Look for a window owned by the parent process - if let matchingWindow = filteredWindows.first(where: { window in + // Look for windows owned by the parent process + let parentPIDWindows = filteredWindows.filter { window in window.ownerPID == parentPID - }) { - logger.info("Found window by parent process match: PID \(parentPID)") - return matchingWindow + } + + if parentPIDWindows.count == 1 { + logger.info("Found single window by parent process match: PID \(parentPID)") + return parentPIDWindows.first + } else if parentPIDWindows.count > 1 { + logger.info("Found \(parentPIDWindows.count) windows for PID \(parentPID), checking session ID in titles") + + // Multiple windows - try to match by session ID in title + if let matchingWindow = parentPIDWindows.first(where: { window in + window.title?.contains("Session \(sessionID)") ?? false + }) { + logger.info("Found window by session ID '\(sessionID)' in title") + return matchingWindow + } + + // If no session ID match, return first window + logger.warning("No window with session ID in title, using first window") + return parentPIDWindows.first } // If direct parent match fails, try to find grandparent or higher ancestors @@ -52,14 +68,32 @@ final class WindowMatcher { if let grandParentPID = processTracker.getParentProcessID(of: currentPID) { logger.debug("Checking ancestor process PID: \(grandParentPID) at depth \(depth + 2)") - if let matchingWindow = filteredWindows.first(where: { window in + let ancestorPIDWindows = filteredWindows.filter { window in window.ownerPID == grandParentPID - }) { + } + + if ancestorPIDWindows.count == 1 { logger .info( - "Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)" + "Found single window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)" ) - return matchingWindow + return ancestorPIDWindows.first + } else if ancestorPIDWindows.count > 1 { + logger + .info( + "Found \(ancestorPIDWindows.count) windows for ancestor PID \(grandParentPID), checking session ID" + ) + + // Multiple windows - try to match by session ID in title + if let matchingWindow = ancestorPIDWindows.first(where: { window in + window.title?.contains("Session \(sessionID)") ?? false + }) { + logger.info("Found window by session ID '\(sessionID)' in title") + return matchingWindow + } + + // If no session ID match, return first window + return ancestorPIDWindows.first } currentPID = grandParentPID @@ -210,7 +244,7 @@ final class WindowMatcher { } /// Find matching tab using accessibility APIs - func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? { + func findMatchingTab(tabs: [AXElement], sessionInfo: ServerSessionInfo?) -> AXElement? { guard let sessionInfo else { return nil } let workingDir = sessionInfo.workingDir @@ -226,10 +260,7 @@ final class WindowMatcher { logger.debug(" Activity: \(activityStatus ?? "none")") for (index, tab) in tabs.enumerated() { - var titleValue: CFTypeRef? - if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, - let title = titleValue as? String - { + if let title = tab.title { logger.debug("Tab \(index) title: \(title)") // Check for session ID match first (most precise) diff --git a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift index e13a0a05..5840f24e 100644 --- a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift +++ b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift @@ -459,7 +459,7 @@ struct CustomMenuContainer: View { Color.black.opacity(0.25) } } - + private var borderColor: Color { switch colorScheme { case .dark: diff --git a/mac/VibeTunnel/Presentation/Components/Menu/GitRepositoryRow.swift b/mac/VibeTunnel/Presentation/Components/Menu/GitRepositoryRow.swift index 8e02a80d..db06803d 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/GitRepositoryRow.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/GitRepositoryRow.swift @@ -92,7 +92,7 @@ struct GitRepositoryRow: View { private var backgroundFillColor: Color { // Show background on hover - stronger in light mode if isHovering { - return colorScheme == .light + return colorScheme == .light ? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25) : AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15) } @@ -123,10 +123,10 @@ struct GitRepositoryRow: View { Text("•") .font(.system(size: 8)) .foregroundColor(.secondary.opacity(0.5)) - + changeIndicators } - + Spacer() } .padding(.horizontal, 4) diff --git a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift index 0813c40c..e155e05d 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift @@ -29,9 +29,9 @@ struct SessionRow: View { @State private var editedName = "" @State private var isHoveringFolder = false @FocusState private var isEditFieldFocused: Bool - - // Computed property that reads directly from the monitor's cache - // This will automatically update when the monitor refreshes + + /// Computed property that reads directly from the monitor's cache + /// This will automatically update when the monitor refreshes private var gitRepository: GitRepository? { gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir) } @@ -108,6 +108,17 @@ struct SessionRow: View { } .buttonStyle(.plain) .help("Rename session") + + // Magic wand button for AI assistant sessions + if isAIAssistantSession { + Button(action: sendAIPrompt) { + Image(systemName: "wand.and.rays") + .font(.system(size: 11)) + .foregroundColor(.primary) + } + .buttonStyle(.plain) + .help("Send prompt to update terminal title") + } } } @@ -271,14 +282,14 @@ struct SessionRow: View { } } } - + Divider() - + Button("Copy Branch Name") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string) } - + Button("Copy Repository Path") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(repo.path, forType: .string) @@ -322,13 +333,14 @@ struct SessionRow: View { private func getGitAppName() -> String { if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"), !preferredApp.isEmpty, - let gitApp = GitApp(rawValue: preferredApp) { + let gitApp = GitApp(rawValue: preferredApp) + { return gitApp.displayName } // Return first installed git app or default return GitApp.installed.first?.displayName ?? "Git App" } - + private func terminateSession() { isTerminating = true @@ -374,6 +386,16 @@ struct SessionRow: View { } } + private var isAIAssistantSession: Bool { + // Check if this is an AI assistant session by looking at the command + let cmd = commandName.lowercased() + return cmd == "claude" || cmd.contains("claude") || + cmd == "gemini" || cmd.contains("gemini") || + cmd == "openhands" || cmd.contains("openhands") || + cmd == "aider" || cmd.contains("aider") || + cmd == "codex" || cmd.contains("codex") + } + private var sessionName: String { // Use the session name if available, otherwise fall back to directory name if let name = session.value.name, !name.isEmpty { @@ -420,6 +442,22 @@ struct SessionRow: View { } } + private func sendAIPrompt() { + Task { + do { + // Send a prompt that encourages the AI assistant to use vt title + let prompt = "use vt title to update the terminal title with what you're currently working on" + try await sessionService.sendInput(to: session.key, text: prompt) + + // Send Enter key to submit the prompt + try await sessionService.sendKey(to: session.key, key: "enter") + } catch { + // Silently handle errors for now + print("Failed to send prompt to AI assistant: \(error)") + } + } + } + private var compactPath: String { let path = session.value.workingDir let homeDir = NSHomeDirectory() diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index 8b77aef1..55402dab 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -153,10 +153,10 @@ final class StatusBarMenuManager: NSObject { // Start monitoring git repositories for updates every 5 seconds self?.gitRepositoryMonitor?.startMonitoring() } - + customWindow?.onHide = { [weak self] in self?.statusBarButton?.highlight(false) - + // Stop monitoring git repositories when menu closes self?.gitRepositoryMonitor?.stopMonitoring() diff --git a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift index 69cb7898..537ea73c 100644 --- a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift +++ b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift @@ -1,7 +1,7 @@ -import os -import SwiftUI -@preconcurrency import ScreenCaptureKit import ApplicationServices +import os +@preconcurrency import ScreenCaptureKit +import SwiftUI /// View displaying detailed information about a specific terminal session. /// @@ -85,36 +85,39 @@ struct SessionDetailView: View { } } .frame(minWidth: 400) - + Divider() - + // Right side: Window Information and Screenshot VStack(alignment: .leading, spacing: 20) { Text("Window Information") .font(.title2) .fontWeight(.semibold) - - if let windowInfo = windowInfo { + + if let windowInfo { VStack(alignment: .leading, spacing: 12) { DetailRow(label: "Window ID", value: "\(windowInfo.windowID)") DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName) DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)") - + if let bounds = windowInfo.bounds { - DetailRow(label: "Position", value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))") + DetailRow( + label: "Position", + value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))" + ) DetailRow(label: "Size", value: "\(Int(bounds.width)) × \(Int(bounds.height))") } - + if let title = windowInfo.title { DetailRow(label: "Window Title", value: title) } - + HStack { Button("Focus Window") { focusWindow() } .controlSize(.regular) - + Button("Capture Screenshot") { Task { await captureWindowScreenshot() @@ -124,14 +127,14 @@ struct SessionDetailView: View { .disabled(isCapturingScreenshot) } } - + // Window Screenshot if let screenshot = windowScreenshot { VStack(alignment: .leading, spacing: 8) { Text("Window Preview") .font(.headline) .foregroundColor(.secondary) - + Image(nsImage: screenshot) .resizable() .aspectRatio(contentMode: .fit) @@ -148,12 +151,12 @@ struct SessionDetailView: View { Text("Screen Recording Permission Required") .font(.headline) .foregroundColor(.orange) - + Text("VibeTunnel needs Screen Recording permission to capture window screenshots.") .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - + Button("Open System Settings") { openScreenRecordingSettings() } @@ -169,16 +172,18 @@ struct SessionDetailView: View { Label("No window found", systemImage: "exclamationmark.triangle") .foregroundColor(.orange) .font(.headline) - - Text("Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel.") - .foregroundColor(.secondary) - .font(.caption) - .fixedSize(horizontal: false, vertical: true) + + Text( + "Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel." + ) + .foregroundColor(.secondary) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) } else { Text("No window information available") .foregroundColor(.secondary) } - + Button(isFindingWindow ? "Searching..." : "Find Window") { findWindow() } @@ -187,7 +192,7 @@ struct SessionDetailView: View { } .padding(.vertical, 20) } - + Spacer() } .frame(minWidth: 400) @@ -233,39 +238,48 @@ struct SessionDetailView: View { // TODO: Implement session termination logger.info("Terminating session \(session.id)") } - + private func findWindow() { isFindingWindow = true windowSearchAttempted = true - + Task { @MainActor in defer { isFindingWindow = false } - + logger.info("Looking for window associated with session \(session.id)") - + // First, check if WindowTracker already has window info for this session if let trackedWindow = WindowTracker.shared.windowInfo(for: session.id) { - logger.info("Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)") + logger + .info( + "Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)" + ) self.windowInfo = trackedWindow return } - + logger.info("No tracked window found for session \(session.id), attempting to find it...") - + // Get all terminal windows for debugging let allWindows = WindowEnumerator.getAllTerminalWindows() logger.info("Found \(allWindows.count) terminal windows currently open") - + // Log details about each window for debugging for (index, window) in allWindows.enumerated() { - logger.debug("Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "")") + logger + .debug( + "Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "")" + ) } - + // Log session details for debugging - logger.info("Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)") - + logger + .info( + "Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)" + ) + // Try to match by various criteria if let pid = session.pid { logger.info("Looking for window with PID \(pid)...") @@ -284,11 +298,11 @@ struct SessionDetailView: View { logger.warning("No window found with PID \(pid)") } } - + // Try to find by window title containing working directory let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent logger.info("Looking for window with title containing '\(workingDirName)'...") - + if let window = allWindows.first(where: { window in if let title = window.title { return title.contains(workingDirName) || title.contains(session.id) @@ -306,70 +320,76 @@ struct SessionDetailView: View { ) return } - - logger.warning("Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows") + + logger + .warning( + "Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows" + ) logger.warning("Session may not have an associated terminal window or window detection failed") } } - + private func focusWindow() { // Use WindowTracker's existing focus logic which handles all the complexity logger.info("Attempting to focus window for session \(session.id)") - + // First ensure we have window info if windowInfo == nil { logger.info("No window info cached, trying to find window first...") findWindow() } - - if let windowInfo = windowInfo { - logger.info("Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)") + + if let windowInfo { + logger + .info( + "Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)" + ) WindowTracker.shared.focusWindow(for: session.id) } else { logger.error("Cannot focus window - no window found for session \(session.id)") } } - + private func captureWindowScreenshot() async { - guard let windowInfo = windowInfo else { + guard let windowInfo else { logger.warning("No window info available for screenshot") return } - + await MainActor.run { isCapturingScreenshot = true } - + defer { Task { @MainActor in isCapturingScreenshot = false } } - + // Check for screen recording permission let hasPermission = await checkScreenCapturePermission() await MainActor.run { hasScreenCapturePermission = hasPermission } - + guard hasPermission else { logger.warning("No screen capture permission") return } - + do { // Get available content let availableContent = try await SCShareableContent.current - + // Find the window guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else { logger.warning("Window not found in shareable content") return } - + // Create content filter for this specific window let filter = SCContentFilter(desktopIndependentWindow: window) - + // Configure the capture let config = SCStreamConfiguration() config.width = Int(window.frame.width * 2) // Retina resolution @@ -377,39 +397,38 @@ struct SessionDetailView: View { config.scalesToFit = true config.showsCursor = false config.captureResolution = .best - + // Capture the screenshot let screenshot = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) - + // Convert CGImage to NSImage let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height)) - + await MainActor.run { self.windowScreenshot = nsImage } - + logger.info("Successfully captured window screenshot") - } catch { logger.error("Failed to capture screenshot: \(error)") } } - + private func checkScreenCapturePermission() async -> Bool { // Check if we have screen recording permission let hasPermission = CGPreflightScreenCaptureAccess() - + if !hasPermission { // Request permission return CGRequestScreenCaptureAccess() } - + return true } - + private func openScreenRecordingSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(url) diff --git a/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift index b50a1d6c..ad4ea874 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift @@ -111,6 +111,9 @@ struct AdvancedSettingsView: View { .multilineTextAlignment(.center) } + // Window Highlight section + WindowHighlightSettingsSection() + // Advanced section Section { VStack(alignment: .leading, spacing: 4) { @@ -345,7 +348,7 @@ private struct TerminalPreferenceSection: View { private var gitAppBinding: Binding { Binding( - get: { + get: { // If no preference or invalid preference, use first installed app if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil { return GitApp.installed.first?.rawValue ?? "" @@ -358,3 +361,180 @@ private struct TerminalPreferenceSection: View { ) } } + +// MARK: - Window Highlight Settings Section + +private struct WindowHighlightSettingsSection: View { + @AppStorage("windowHighlightEnabled") + private var highlightEnabled = true + @AppStorage("windowHighlightStyle") + private var highlightStyle = "default" + @AppStorage("windowHighlightColor") + private var highlightColorData = Data() + + @State private var customColor = Color.blue + @State private var highlightEffect: WindowHighlightEffect? + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + // Enable/Disable toggle + Toggle("Show window highlight effect", isOn: $highlightEnabled) + .onChange(of: highlightEnabled) { _, newValue in + if newValue { + previewHighlightEffect() + } + } + + if highlightEnabled { + // Style picker + Picker("Highlight style", selection: $highlightStyle) { + Text("Default").tag("default") + Text("Subtle").tag("subtle") + Text("Neon").tag("neon") + Text("Custom").tag("custom") + } + .pickerStyle(.segmented) + .onChange(of: highlightStyle) { _, _ in + previewHighlightEffect() + } + + // Custom color picker (only shown when custom is selected) + if highlightStyle == "custom" { + HStack { + Text("Custom color") + Spacer() + ColorPicker("", selection: $customColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: customColor) { _, newColor in + saveCustomColor(newColor) + previewHighlightEffect() + } + } + } + } + } + } header: { + Text("Window Highlight") + .font(.headline) + } footer: { + Text("Visual effect when focusing terminal windows to make selection more noticeable.") + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + .onAppear { + loadCustomColor() + // Create highlight effect instance for preview + highlightEffect = WindowHighlightEffect() + } + } + + private func saveCustomColor(_ color: Color) { + let nsColor = NSColor(color) + do { + let data = try NSKeyedArchiver.archivedData(withRootObject: nsColor, requiringSecureCoding: false) + highlightColorData = data + } catch { + Logger.advanced.error("Failed to save custom color: \(error)") + } + } + + private func loadCustomColor() { + if !highlightColorData.isEmpty { + do { + if let nsColor = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: highlightColorData) { + customColor = Color(nsColor) + } + } catch { + Logger.advanced.error("Failed to load custom color: \(error)") + } + } + } + + private func previewHighlightEffect() { + Task { @MainActor in + // Get the current highlight configuration + let config = loadCurrentHighlightConfig() + + // Update the highlight effect with new config + highlightEffect?.updateConfig(config) + + // Find the settings window + guard let settingsWindow = NSApp.windows.first(where: { window in + window.title.contains("Settings") || window.title.contains("Preferences") + }) else { + Logger.advanced.debug("Could not find settings window for highlight preview") + return + } + + // Get the window's accessibility element + let pid = ProcessInfo.processInfo.processIdentifier + let axApp = AXElement.application(pid: pid) + + guard let windows = axApp.windows, !windows.isEmpty else { + Logger.advanced.debug("Could not get accessibility windows for highlight preview") + return + } + + // Find the settings window by comparing bounds + let settingsFrame = settingsWindow.frame + var targetWindow: AXElement? + + for axWindow in windows { + if let frame = axWindow.frame() { + // Check if this matches our settings window (with some tolerance for frame differences) + let tolerance: CGFloat = 5.0 + if abs(frame.origin.x - settingsFrame.origin.x) < tolerance && + abs(frame.width - settingsFrame.width) < tolerance && + abs(frame.height - settingsFrame.height) < tolerance { + targetWindow = axWindow + break + } + } + } + + // Apply highlight effect to the settings window + if let window = targetWindow { + highlightEffect?.highlightWindow(window) + } else { + Logger.advanced.debug("Could not match settings window for highlight preview") + } + } + } + + private func loadCurrentHighlightConfig() -> WindowHighlightConfig { + guard highlightEnabled else { + return WindowHighlightConfig( + color: .clear, + duration: 0, + borderWidth: 0, + glowRadius: 0, + isEnabled: false + ) + } + + switch highlightStyle { + case "subtle": + return .subtle + case "neon": + return .neon + case "custom": + // Load custom color + let colorData = highlightColorData + if !colorData.isEmpty, + let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + return WindowHighlightConfig( + color: nsColor, + duration: 0.8, + borderWidth: 4.0, + glowRadius: 12.0, + isEnabled: true + ) + } + return .default + default: + return .default + } + } +} diff --git a/mac/VibeTunnel/Presentation/Views/SettingsView.swift b/mac/VibeTunnel/Presentation/Views/SettingsView.swift index 062fe72a..0c5c8aee 100644 --- a/mac/VibeTunnel/Presentation/Views/SettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/SettingsView.swift @@ -12,7 +12,7 @@ struct SettingsView: View { private var debugMode = false // MARK: - Constants - + private enum Layout { static let defaultTabSize = CGSize(width: 500, height: 620) static let fallbackTabSize = CGSize(width: 500, height: 400) diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift index 8d19ce5d..18b1baac 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift @@ -37,12 +37,12 @@ struct VTCommandPageView: View { .frame(maxWidth: 480) .fixedSize(horizontal: false, vertical: true) - Text("For example, to remote control Claude Code, type:") + Text("For example, to remote control AI assistants, type:") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - Text("vt claude") + Text("vt claude or vt gemini") .font(.system(.body, design: .monospaced)) .foregroundColor(.primary) .padding(.horizontal, 16) diff --git a/mac/VibeTunnel/Utilities/TerminalLauncher.swift b/mac/VibeTunnel/Utilities/TerminalLauncher.swift index 9f52d6e6..30d5ed4d 100644 --- a/mac/VibeTunnel/Utilities/TerminalLauncher.swift +++ b/mac/VibeTunnel/Utilities/TerminalLauncher.swift @@ -644,6 +644,8 @@ final class TerminalLauncher { activate set newWindow to (create window with default profile) tell current session of newWindow + -- Set session name to include session ID for easier matching + set name to "Session \(sessionId)" write text "\(config.appleScriptEscapedCommand)" end tell return id of newWindow diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 2745452c..fefda1f3 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -192,6 +192,35 @@ export class SessionView extends LitElement { if (this.session && sessionId === this.session.id) { this.session = { ...this.session, status: 'exited' }; this.requestUpdate(); + + // Check if this window should auto-close + // Only attempt to close if we're on a session-specific URL + const urlParams = new URLSearchParams(window.location.search); + const sessionParam = urlParams.get('session'); + + if (sessionParam === sessionId) { + // This window was opened specifically for this session + logger.log(`Session ${sessionId} exited, attempting to close window`); + + // Try to close the window + // This will work for: + // 1. Windows opened via window.open() from JavaScript + // 2. Windows where the user has granted permission + // It won't work for regular browser tabs, which is fine + setTimeout(() => { + try { + window.close(); + + // If window.close() didn't work (we're still here after 100ms), + // show a message to the user + setTimeout(() => { + logger.log('Window close failed - likely opened as a regular tab'); + }, 100); + } catch (e) { + logger.warn('Failed to close window:', e); + } + }, 500); // Give user time to see the "exited" status + } } }, (session: Session) => { diff --git a/web/src/client/components/session-view/connection-manager.ts b/web/src/client/components/session-view/connection-manager.ts index 76f42487..7d541db1 100644 --- a/web/src/client/components/session-view/connection-manager.ts +++ b/web/src/client/components/session-view/connection-manager.ts @@ -17,6 +17,7 @@ export interface StreamConnection { eventSource: EventSource; disconnect: () => void; errorHandler?: EventListener; + sessionExitHandler?: EventListener; } export class ConnectionManager { @@ -72,6 +73,20 @@ export class ConnectionManager { // Use CastConverter to connect terminal to stream with reconnection tracking const connection = CastConverter.connectToStream(this.terminal, streamUrl); + // Listen for session-exit events from the terminal + const handleSessionExit = (event: Event) => { + const customEvent = event as CustomEvent; + const sessionId = customEvent.detail?.sessionId || this.session?.id; + + logger.log(`Received session-exit event for session ${sessionId}`); + + if (sessionId) { + this.onSessionExit(sessionId); + } + }; + + this.terminal.addEventListener('session-exit', handleSessionExit); + // Wrap the connection to track reconnections const originalEventSource = connection.eventSource; let lastErrorTime = 0; @@ -114,16 +129,23 @@ export class ConnectionManager { // Override the error handler originalEventSource.addEventListener('error', handleError); - // Store the connection with error handler reference + // Store the connection with error handler reference and session-exit handler this.streamConnection = { ...connection, errorHandler: handleError as EventListener, + sessionExitHandler: handleSessionExit as EventListener, }; } cleanupStreamConnection(): void { if (this.streamConnection) { logger.log('Cleaning up stream connection'); + + // Remove session-exit event listener if it exists + if (this.streamConnection.sessionExitHandler && this.terminal) { + this.terminal.removeEventListener('session-exit', this.streamConnection.sessionExitHandler); + } + this.streamConnection.disconnect(); this.streamConnection = null; } diff --git a/web/src/server/fwd.ts b/web/src/server/fwd.ts index 7bf706af..0b8ae287 100755 --- a/web/src/server/fwd.ts +++ b/web/src/server/fwd.ts @@ -77,7 +77,7 @@ export async function startVibeTunnelForward(args: string[]) { process.exit(0); } - logger.log(chalk.blue(`VibeTunnel Forward v${VERSION}`) + chalk.gray(` (${BUILD_DATE})`)); + logger.debug(chalk.blue(`VibeTunnel Forward v${VERSION}`) + chalk.gray(` (${BUILD_DATE})`)); logger.debug(`Full command: ${args.join(' ')}`); // Parse command line arguments @@ -195,7 +195,7 @@ export async function startVibeTunnelForward(args: string[]) { const sent = socketClient.updateTitle(sanitizedTitle); if (sent) { - logger.log(`Session title updated via IPC to: ${sanitizedTitle}`); + logger.debug(`Session title updated via IPC to: ${sanitizedTitle}`); // IPC update succeeded, server will handle the file update socketClient.disconnect(); closeLogger(); diff --git a/web/src/server/pty/pty-manager.ts b/web/src/server/pty/pty-manager.ts index 1401f47a..fedfd95d 100644 --- a/web/src/server/pty/pty-manager.ts +++ b/web/src/server/pty/pty-manager.ts @@ -438,6 +438,10 @@ export class PtyManager extends EventEmitter { session.stdoutQueue = stdoutQueue; } + // Create write queue for input to prevent race conditions + const inputQueue = new WriteQueue(); + session.inputQueue = inputQueue; + // Setup activity detector for dynamic mode if (session.titleMode === TitleMode.DYNAMIC) { session.activityDetector = new ActivityDetector(session.sessionInfo.command); @@ -691,11 +695,15 @@ export class PtyManager extends EventEmitter { switch (type) { case MessageType.STDIN_DATA: { const text = data as string; - if (session.ptyProcess) { - // Write input first for fastest response - session.ptyProcess.write(text); - // Then record it (non-blocking) - session.asciinemaWriter?.writeInput(text); + if (session.ptyProcess && session.inputQueue) { + // Queue input write to prevent race conditions + session.inputQueue.enqueue(() => { + if (session.ptyProcess) { + session.ptyProcess.write(text); + } + // Record it (non-blocking) + session.asciinemaWriter?.writeInput(text); + }); } break; } @@ -793,26 +801,31 @@ export class PtyManager extends EventEmitter { // If we have an in-memory session with active PTY, use it const memorySession = this.sessions.get(sessionId); - if (memorySession?.ptyProcess) { - memorySession.ptyProcess.write(dataToSend); - memorySession.asciinemaWriter?.writeInput(dataToSend); - - // Track directory changes for title modes that need it - if ( - (memorySession.titleMode === TitleMode.STATIC || - memorySession.titleMode === TitleMode.DYNAMIC) && - input.text - ) { - const newDir = extractCdDirectory( - input.text, - memorySession.currentWorkingDir || memorySession.sessionInfo.workingDir - ); - if (newDir) { - memorySession.currentWorkingDir = newDir; - this.markTitleUpdateNeeded(memorySession); - logger.debug(`Session ${sessionId} changed directory to: ${newDir}`); + if (memorySession?.ptyProcess && memorySession.inputQueue) { + // Queue input write to prevent race conditions + memorySession.inputQueue.enqueue(() => { + if (memorySession.ptyProcess) { + memorySession.ptyProcess.write(dataToSend); } - } + memorySession.asciinemaWriter?.writeInput(dataToSend); + + // Track directory changes for title modes that need it + if ( + (memorySession.titleMode === TitleMode.STATIC || + memorySession.titleMode === TitleMode.DYNAMIC) && + input.text + ) { + const newDir = extractCdDirectory( + input.text, + memorySession.currentWorkingDir || memorySession.sessionInfo.workingDir + ); + if (newDir) { + memorySession.currentWorkingDir = newDir; + this.markTitleUpdateNeeded(memorySession); + logger.debug(`Session ${sessionId} changed directory to: ${newDir}`); + } + } + }); return; // Important: return here to avoid socket path } else { diff --git a/web/src/server/pty/types.ts b/web/src/server/pty/types.ts index 5631a7a6..a79fa615 100644 --- a/web/src/server/pty/types.ts +++ b/web/src/server/pty/types.ts @@ -70,6 +70,7 @@ export interface PtySession { // Optional fields for resource cleanup inputSocketServer?: net.Server; stdoutQueue?: WriteQueue; + inputQueue?: WriteQueue; // Terminal title mode titleMode?: TitleMode; // Track current working directory for title updates