mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Add magic wand button to inject prompts into Claude sessions (#210)
This commit is contained in:
parent
359824a56f
commit
d3d0ce9dde
23 changed files with 1997 additions and 269 deletions
37
CHANGELOG.md
37
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
404
mac/VibeTunnel/Core/Accessibility/AXElement.swift
Normal file
404
mac/VibeTunnel/Core/Accessibility/AXElement.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
154
mac/VibeTunnel/Core/Accessibility/AXPermissions.swift
Normal file
154
mac/VibeTunnel/Core/Accessibility/AXPermissions.swift
Normal file
|
|
@ -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<Bool> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<String> = []
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -459,7 +459,7 @@ struct CustomMenuContainer<Content: View>: View {
|
|||
Color.black.opacity(0.25)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var borderColor: Color {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "<no title>")")
|
||||
logger
|
||||
.debug(
|
||||
"Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "<no 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)
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue