mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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
|
# 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
|
## [1.0.0-beta.5] - upcoming
|
||||||
|
|
||||||
### 🎯 Features
|
### 🎯 Features
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// VibeTunnel Version Configuration
|
// VibeTunnel Version Configuration
|
||||||
// This file contains the version and build number for the app
|
// This file contains the version and build number for the app
|
||||||
|
|
||||||
MARKETING_VERSION = 1.0.0-beta.6
|
MARKETING_VERSION = 1.0.0-beta.7
|
||||||
CURRENT_PROJECT_VERSION = 152
|
CURRENT_PROJECT_VERSION = 160
|
||||||
|
|
||||||
// Domain and GitHub configuration
|
// Domain and GitHub configuration
|
||||||
APP_DOMAIN = vibetunnel.sh
|
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
|
/// 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 {
|
func terminateSession(sessionId: String) async throws {
|
||||||
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else {
|
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else {
|
||||||
throw SessionServiceError.invalidURL
|
throw SessionServiceError.invalidURL
|
||||||
|
|
@ -68,9 +83,79 @@ final class SessionService {
|
||||||
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
|
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
|
// 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
|
/// Create a new session
|
||||||
func createSession(
|
func createSession(
|
||||||
command: [String],
|
command: [String],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,29 @@ import OSLog
|
||||||
/// - Map VibeTunnel sessions to their terminal windows
|
/// - Map VibeTunnel sessions to their terminal windows
|
||||||
/// - Focus specific terminal windows when requested
|
/// - Focus specific terminal windows when requested
|
||||||
/// - Handle both windows and tabs for different terminal applications
|
/// - 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
|
@MainActor
|
||||||
final class WindowTracker {
|
final class WindowTracker {
|
||||||
static let shared = WindowTracker()
|
static let shared = WindowTracker()
|
||||||
|
|
@ -21,6 +44,24 @@ final class WindowTracker {
|
||||||
/// Maps session IDs to their terminal window information
|
/// Maps session IDs to their terminal window information
|
||||||
private var sessionWindowMap: [String: WindowEnumerator.WindowInfo] = [:]
|
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
|
/// Lock for thread-safe access to the session map
|
||||||
private let mapLock = NSLock()
|
private let mapLock = NSLock()
|
||||||
|
|
||||||
|
|
@ -37,70 +78,37 @@ final class WindowTracker {
|
||||||
|
|
||||||
// MARK: - Window Registration
|
// 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.
|
/// 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(
|
func registerWindow(
|
||||||
for sessionID: String,
|
for sessionID: String,
|
||||||
terminalApp: Terminal,
|
terminalApp: Terminal,
|
||||||
tabReference: String? = nil,
|
tabReference: String? = nil,
|
||||||
tabID: String? = nil
|
tabID: String? = nil
|
||||||
) {
|
) {
|
||||||
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
|
// Simply mark the session as opened by us
|
||||||
|
// We no longer store tab references as they become stale
|
||||||
// For Terminal.app and iTerm2 with explicit window/tab info, register immediately
|
registerSessionOpenedByUs(for: sessionID, terminalApp: terminalApp)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregisters a window for a session.
|
/// Unregisters a window for a session.
|
||||||
|
|
@ -109,6 +117,7 @@ final class WindowTracker {
|
||||||
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
||||||
logger.info("Unregistered window for session: \(sessionID)")
|
logger.info("Unregistered window for session: \(sessionID)")
|
||||||
}
|
}
|
||||||
|
sessionsOpenedByUs.remove(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +157,153 @@ final class WindowTracker {
|
||||||
windowFocuser.focusWindow(windowInfo)
|
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
|
// MARK: - Permission Management
|
||||||
|
|
||||||
/// Check if we have the required permissions.
|
/// Check if we have the required permissions.
|
||||||
|
|
@ -178,6 +334,17 @@ final class WindowTracker {
|
||||||
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
||||||
logger.info("Removed window tracking for terminated session: \(sessionID)")
|
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 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
|
/// Focus a window based on terminal type
|
||||||
func focusWindow(_ windowInfo: WindowEnumerator.WindowInfo) {
|
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)
|
/// Get the first tab group in a window (improved approach based on screenshot)
|
||||||
private func getTabGroup(from window: AXUIElement) -> AXUIElement? {
|
private func getTabGroup(from window: AXElement) -> AXElement? {
|
||||||
var childrenRef: CFTypeRef?
|
guard let children = window.children else {
|
||||||
guard AXUIElementCopyAttributeValue(
|
|
||||||
window,
|
|
||||||
kAXChildrenAttribute as CFString,
|
|
||||||
&childrenRef
|
|
||||||
) == .success,
|
|
||||||
let children = childrenRef as? [AXUIElement]
|
|
||||||
else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first element with role kAXTabGroupRole
|
// Find the first element with role kAXTabGroupRole
|
||||||
return children.first { elem in
|
return children.first { elem in
|
||||||
var roleRef: CFTypeRef?
|
elem.role == kAXTabGroupRole
|
||||||
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
|
|
||||||
return (roleRef as? String) == kAXTabGroupRole as String
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the correct tab in a window that uses macOS standard tabs
|
/// Select the correct tab in a window that uses macOS standard tabs
|
||||||
private func selectTab(
|
private func selectTab(
|
||||||
tabs: [AXUIElement],
|
tabs: [AXElement],
|
||||||
windowInfo: WindowEnumerator.WindowInfo,
|
windowInfo: WindowEnumerator.WindowInfo,
|
||||||
sessionInfo: ServerSessionInfo?
|
sessionInfo: ServerSessionInfo?
|
||||||
) {
|
) {
|
||||||
|
|
@ -160,22 +217,14 @@ final class WindowFocuser {
|
||||||
// Try to find the correct tab
|
// Try to find the correct tab
|
||||||
if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) {
|
if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) {
|
||||||
// Found matching tab - select it using kAXPressAction (most reliable)
|
// Found matching tab - select it using kAXPressAction (most reliable)
|
||||||
let result = AXUIElementPerformAction(matchingTab, kAXPressAction as CFString)
|
if matchingTab.press() {
|
||||||
if result == .success {
|
|
||||||
logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)")
|
logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)")
|
||||||
} else {
|
} 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
|
// Try alternative selection method - set as selected
|
||||||
var selectedValue: CFTypeRef?
|
if matchingTab.isAttributeSettable(kAXSelectedAttribute) {
|
||||||
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) ==
|
let setResult = matchingTab.setSelected(true)
|
||||||
.success
|
|
||||||
{
|
|
||||||
let setResult = AXUIElementSetAttributeValue(
|
|
||||||
matchingTab,
|
|
||||||
kAXSelectedAttribute as CFString,
|
|
||||||
true as CFTypeRef
|
|
||||||
)
|
|
||||||
if setResult == .success {
|
if setResult == .success {
|
||||||
logger.info("Selected tab using AXSelected attribute")
|
logger.info("Selected tab using AXSelected attribute")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -185,7 +234,7 @@ final class WindowFocuser {
|
||||||
}
|
}
|
||||||
} else if tabs.count == 1 {
|
} else if tabs.count == 1 {
|
||||||
// If only one tab, select it
|
// If only one tab, select it
|
||||||
AXUIElementPerformAction(tabs[0], kAXPressAction as CFString)
|
tabs[0].press()
|
||||||
logger.info("Selected the only available tab")
|
logger.info("Selected the only available tab")
|
||||||
} else {
|
} else {
|
||||||
// Multiple tabs but no match - try to find by index or select first
|
// 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
|
// Log tab titles for debugging
|
||||||
for (index, tab) in tabs.enumerated() {
|
for (index, tab) in tabs.enumerated() {
|
||||||
var titleValue: CFTypeRef?
|
if let title = tab.title {
|
||||||
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
|
||||||
let title = titleValue as? String
|
|
||||||
{
|
|
||||||
logger.debug(" Tab \(index): \(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)
|
/// Select a tab by index in a tab group (helper method from screenshot)
|
||||||
private func selectTab(at index: Int, in group: AXUIElement) -> Bool {
|
private func selectTab(at index: Int, in group: AXElement) -> Bool {
|
||||||
var tabsRef: CFTypeRef?
|
guard let tabs = group.tabs,
|
||||||
guard AXUIElementCopyAttributeValue(
|
index < tabs.count
|
||||||
group,
|
|
||||||
"AXTabs" as CFString,
|
|
||||||
&tabsRef
|
|
||||||
) == .success,
|
|
||||||
let tabs = tabsRef as? [AXUIElement],
|
|
||||||
index < tabs.count
|
|
||||||
else {
|
else {
|
||||||
logger.warning("Could not get tabs from group or index out of bounds")
|
logger.warning("Could not get tabs from group or index out of bounds")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tabs[index].press()
|
||||||
|
}
|
||||||
|
|
||||||
let result = AXUIElementPerformAction(tabs[index], kAXPressAction as CFString)
|
/// Focuses a window by using the process PID directly
|
||||||
return result == .success
|
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.
|
/// Focuses a window using Accessibility APIs.
|
||||||
private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) {
|
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
|
// First bring the application to front
|
||||||
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
||||||
app.activate()
|
app.activate()
|
||||||
logger.info("Activated application with PID: \(windowInfo.ownerPID)")
|
logger.info("Activated application with PID: \(windowInfo.ownerPID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AXUIElement to focus the specific window
|
// Use AXElement to focus the specific window
|
||||||
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
|
let axApp = AXElement.application(pid: windowInfo.ownerPID)
|
||||||
|
|
||||||
var windowsValue: CFTypeRef?
|
guard let windows = axApp.windows,
|
||||||
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
|
|
||||||
|
|
||||||
guard result == .success,
|
|
||||||
let windows = windowsValue as? [AXUIElement],
|
|
||||||
!windows.isEmpty
|
!windows.isEmpty
|
||||||
else {
|
else {
|
||||||
logger.error("Failed to get windows for application")
|
logger.error("Failed to get windows for application")
|
||||||
return
|
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
|
// Get session info for tab matching
|
||||||
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
|
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
|
||||||
|
|
||||||
// First, try to find window with matching tab content
|
// First, try to find window with matching tab content
|
||||||
var foundWindowWithTab = false
|
var bestMatchWindow: (window: AXElement, score: Int)?
|
||||||
|
|
||||||
for (index, window) in windows.enumerated() {
|
for (index, window) in windows.enumerated() {
|
||||||
// Check different window ID attributes (different apps use different ones)
|
var matchScore = 0
|
||||||
var windowMatches = false
|
var windowMatches = false
|
||||||
|
|
||||||
// Try _AXWindowNumber (used by many apps)
|
// Try window ID attribute for matching
|
||||||
var windowIDValue: CFTypeRef?
|
if let axWindowID = window.windowID {
|
||||||
if AXUIElementCopyAttributeValue(window, "_AXWindowNumber" as CFString, &windowIDValue) == .success,
|
if axWindowID == windowInfo.windowID {
|
||||||
let axWindowID = windowIDValue as? Int
|
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)
|
// Check if bounds approximately match (within 5 pixels tolerance)
|
||||||
logger.debug("Window \(index) _AXWindowNumber: \(axWindowID), matches: \(windowMatches)")
|
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
|
// Try the improved approach: get tab group first
|
||||||
if let tabGroup = getTabGroup(from: window) {
|
if let tabGroup = getTabGroup(from: window) {
|
||||||
// Get tabs from the tab group
|
// Get tabs from the tab group
|
||||||
var tabsValue: CFTypeRef?
|
if let tabs = tabGroup.tabs,
|
||||||
if AXUIElementCopyAttributeValue(tabGroup, "AXTabs" as CFString, &tabsValue) == .success,
|
|
||||||
let tabs = tabsValue as? [AXUIElement],
|
|
||||||
!tabs.isEmpty
|
!tabs.isEmpty
|
||||||
{
|
{
|
||||||
logger.info("Window \(index) has tab group with \(tabs.count) tabs")
|
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
|
// Found the tab! Focus the window and select the tab
|
||||||
logger.info("Found matching tab in window \(index)")
|
logger.info("Found matching tab in window \(index)")
|
||||||
|
|
||||||
|
// Show highlight effect
|
||||||
|
highlightEffect.highlightWindow(window, bounds: window.frame())
|
||||||
|
|
||||||
// Make window main and focused
|
// Make window main and focused
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
window.setMain(true)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
window.setFocused(true)
|
||||||
|
|
||||||
// Select the tab
|
// Select the tab
|
||||||
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
||||||
|
|
||||||
foundWindowWithTab = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Try direct tabs attribute (older approach)
|
// Fallback: Try direct tabs attribute (older approach)
|
||||||
var tabsValue: CFTypeRef?
|
if let tabs = window.tabs,
|
||||||
let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue)
|
|
||||||
|
|
||||||
if hasTabsResult == .success,
|
|
||||||
let tabs = tabsValue as? [AXUIElement],
|
|
||||||
!tabs.isEmpty
|
!tabs.isEmpty
|
||||||
{
|
{
|
||||||
logger.info("Window \(index) has \(tabs.count) tabs (direct attribute)")
|
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
|
// Found the tab! Focus the window and select the tab
|
||||||
logger.info("Found matching tab in window \(index)")
|
logger.info("Found matching tab in window \(index)")
|
||||||
|
|
||||||
|
// Show highlight effect
|
||||||
|
highlightEffect.highlightWindow(window, bounds: window.frame())
|
||||||
|
|
||||||
// Make window main and focused
|
// Make window main and focused
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
window.setMain(true)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
window.setFocused(true)
|
||||||
|
|
||||||
// Select the tab
|
// Select the tab
|
||||||
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
||||||
|
|
||||||
foundWindowWithTab = true
|
|
||||||
return
|
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
|
// After checking all windows, use the best match if we found one
|
||||||
if !foundWindowWithTab && !windows.isEmpty {
|
if let bestMatch = bestMatchWindow {
|
||||||
logger.warning("No window found with matching tab, focusing first window")
|
logger.info("Using best match window with score \(bestMatch.score) for window ID \(windowInfo.windowID)")
|
||||||
let firstWindow = windows[0]
|
|
||||||
AXUIElementSetAttributeValue(firstWindow, kAXMainAttribute as CFString, true as CFTypeRef)
|
// Show highlight effect
|
||||||
AXUIElementSetAttributeValue(firstWindow, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
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)) {
|
if let parentPID = processTracker.getParentProcessID(of: pid_t(sessionPID)) {
|
||||||
logger.debug("Found parent process PID: \(parentPID)")
|
logger.debug("Found parent process PID: \(parentPID)")
|
||||||
|
|
||||||
// Look for a window owned by the parent process
|
// Look for windows owned by the parent process
|
||||||
if let matchingWindow = filteredWindows.first(where: { window in
|
let parentPIDWindows = filteredWindows.filter { window in
|
||||||
window.ownerPID == parentPID
|
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
|
// 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) {
|
if let grandParentPID = processTracker.getParentProcessID(of: currentPID) {
|
||||||
logger.debug("Checking ancestor process PID: \(grandParentPID) at depth \(depth + 2)")
|
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
|
window.ownerPID == grandParentPID
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
if ancestorPIDWindows.count == 1 {
|
||||||
logger
|
logger
|
||||||
.info(
|
.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
|
currentPID = grandParentPID
|
||||||
|
|
@ -210,7 +244,7 @@ final class WindowMatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find matching tab using accessibility APIs
|
/// 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 }
|
guard let sessionInfo else { return nil }
|
||||||
|
|
||||||
let workingDir = sessionInfo.workingDir
|
let workingDir = sessionInfo.workingDir
|
||||||
|
|
@ -226,10 +260,7 @@ final class WindowMatcher {
|
||||||
logger.debug(" Activity: \(activityStatus ?? "none")")
|
logger.debug(" Activity: \(activityStatus ?? "none")")
|
||||||
|
|
||||||
for (index, tab) in tabs.enumerated() {
|
for (index, tab) in tabs.enumerated() {
|
||||||
var titleValue: CFTypeRef?
|
if let title = tab.title {
|
||||||
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
|
||||||
let title = titleValue as? String
|
|
||||||
{
|
|
||||||
logger.debug("Tab \(index) title: \(title)")
|
logger.debug("Tab \(index) title: \(title)")
|
||||||
|
|
||||||
// Check for session ID match first (most precise)
|
// Check for session ID match first (most precise)
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ struct CustomMenuContainer<Content: View>: View {
|
||||||
Color.black.opacity(0.25)
|
Color.black.opacity(0.25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var borderColor: Color {
|
private var borderColor: Color {
|
||||||
switch colorScheme {
|
switch colorScheme {
|
||||||
case .dark:
|
case .dark:
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ struct GitRepositoryRow: View {
|
||||||
private var backgroundFillColor: Color {
|
private var backgroundFillColor: Color {
|
||||||
// Show background on hover - stronger in light mode
|
// Show background on hover - stronger in light mode
|
||||||
if isHovering {
|
if isHovering {
|
||||||
return colorScheme == .light
|
return colorScheme == .light
|
||||||
? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25)
|
? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25)
|
||||||
: AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15)
|
: AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15)
|
||||||
}
|
}
|
||||||
|
|
@ -123,10 +123,10 @@ struct GitRepositoryRow: View {
|
||||||
Text("•")
|
Text("•")
|
||||||
.font(.system(size: 8))
|
.font(.system(size: 8))
|
||||||
.foregroundColor(.secondary.opacity(0.5))
|
.foregroundColor(.secondary.opacity(0.5))
|
||||||
|
|
||||||
changeIndicators
|
changeIndicators
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ struct SessionRow: View {
|
||||||
@State private var editedName = ""
|
@State private var editedName = ""
|
||||||
@State private var isHoveringFolder = false
|
@State private var isHoveringFolder = false
|
||||||
@FocusState private var isEditFieldFocused: Bool
|
@FocusState private var isEditFieldFocused: Bool
|
||||||
|
|
||||||
// Computed property that reads directly from the monitor's cache
|
/// Computed property that reads directly from the monitor's cache
|
||||||
// This will automatically update when the monitor refreshes
|
/// This will automatically update when the monitor refreshes
|
||||||
private var gitRepository: GitRepository? {
|
private var gitRepository: GitRepository? {
|
||||||
gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
|
gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +108,17 @@ struct SessionRow: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Rename session")
|
.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()
|
Divider()
|
||||||
|
|
||||||
Button("Copy Branch Name") {
|
Button("Copy Branch Name") {
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string)
|
NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Copy Repository Path") {
|
Button("Copy Repository Path") {
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(repo.path, forType: .string)
|
NSPasteboard.general.setString(repo.path, forType: .string)
|
||||||
|
|
@ -322,13 +333,14 @@ struct SessionRow: View {
|
||||||
private func getGitAppName() -> String {
|
private func getGitAppName() -> String {
|
||||||
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
|
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
|
||||||
!preferredApp.isEmpty,
|
!preferredApp.isEmpty,
|
||||||
let gitApp = GitApp(rawValue: preferredApp) {
|
let gitApp = GitApp(rawValue: preferredApp)
|
||||||
|
{
|
||||||
return gitApp.displayName
|
return gitApp.displayName
|
||||||
}
|
}
|
||||||
// Return first installed git app or default
|
// Return first installed git app or default
|
||||||
return GitApp.installed.first?.displayName ?? "Git App"
|
return GitApp.installed.first?.displayName ?? "Git App"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func terminateSession() {
|
private func terminateSession() {
|
||||||
isTerminating = true
|
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 {
|
private var sessionName: String {
|
||||||
// Use the session name if available, otherwise fall back to directory name
|
// Use the session name if available, otherwise fall back to directory name
|
||||||
if let name = session.value.name, !name.isEmpty {
|
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 {
|
private var compactPath: String {
|
||||||
let path = session.value.workingDir
|
let path = session.value.workingDir
|
||||||
let homeDir = NSHomeDirectory()
|
let homeDir = NSHomeDirectory()
|
||||||
|
|
|
||||||
|
|
@ -153,10 +153,10 @@ final class StatusBarMenuManager: NSObject {
|
||||||
// Start monitoring git repositories for updates every 5 seconds
|
// Start monitoring git repositories for updates every 5 seconds
|
||||||
self?.gitRepositoryMonitor?.startMonitoring()
|
self?.gitRepositoryMonitor?.startMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
customWindow?.onHide = { [weak self] in
|
customWindow?.onHide = { [weak self] in
|
||||||
self?.statusBarButton?.highlight(false)
|
self?.statusBarButton?.highlight(false)
|
||||||
|
|
||||||
// Stop monitoring git repositories when menu closes
|
// Stop monitoring git repositories when menu closes
|
||||||
self?.gitRepositoryMonitor?.stopMonitoring()
|
self?.gitRepositoryMonitor?.stopMonitoring()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import os
|
|
||||||
import SwiftUI
|
|
||||||
@preconcurrency import ScreenCaptureKit
|
|
||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
import os
|
||||||
|
@preconcurrency import ScreenCaptureKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
/// View displaying detailed information about a specific terminal session.
|
/// View displaying detailed information about a specific terminal session.
|
||||||
///
|
///
|
||||||
|
|
@ -85,36 +85,39 @@ struct SessionDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 400)
|
.frame(minWidth: 400)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Right side: Window Information and Screenshot
|
// Right side: Window Information and Screenshot
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Text("Window Information")
|
Text("Window Information")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
if let windowInfo = windowInfo {
|
if let windowInfo {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
DetailRow(label: "Window ID", value: "\(windowInfo.windowID)")
|
DetailRow(label: "Window ID", value: "\(windowInfo.windowID)")
|
||||||
DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName)
|
DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName)
|
||||||
DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)")
|
DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)")
|
||||||
|
|
||||||
if let bounds = windowInfo.bounds {
|
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))")
|
DetailRow(label: "Size", value: "\(Int(bounds.width)) × \(Int(bounds.height))")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let title = windowInfo.title {
|
if let title = windowInfo.title {
|
||||||
DetailRow(label: "Window Title", value: title)
|
DetailRow(label: "Window Title", value: title)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Focus Window") {
|
Button("Focus Window") {
|
||||||
focusWindow()
|
focusWindow()
|
||||||
}
|
}
|
||||||
.controlSize(.regular)
|
.controlSize(.regular)
|
||||||
|
|
||||||
Button("Capture Screenshot") {
|
Button("Capture Screenshot") {
|
||||||
Task {
|
Task {
|
||||||
await captureWindowScreenshot()
|
await captureWindowScreenshot()
|
||||||
|
|
@ -124,14 +127,14 @@ struct SessionDetailView: View {
|
||||||
.disabled(isCapturingScreenshot)
|
.disabled(isCapturingScreenshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window Screenshot
|
// Window Screenshot
|
||||||
if let screenshot = windowScreenshot {
|
if let screenshot = windowScreenshot {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Window Preview")
|
Text("Window Preview")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Image(nsImage: screenshot)
|
Image(nsImage: screenshot)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
|
|
@ -148,12 +151,12 @@ struct SessionDetailView: View {
|
||||||
Text("Screen Recording Permission Required")
|
Text("Screen Recording Permission Required")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
Text("VibeTunnel needs Screen Recording permission to capture window screenshots.")
|
Text("VibeTunnel needs Screen Recording permission to capture window screenshots.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
Button("Open System Settings") {
|
Button("Open System Settings") {
|
||||||
openScreenRecordingSettings()
|
openScreenRecordingSettings()
|
||||||
}
|
}
|
||||||
|
|
@ -169,16 +172,18 @@ struct SessionDetailView: View {
|
||||||
Label("No window found", systemImage: "exclamationmark.triangle")
|
Label("No window found", systemImage: "exclamationmark.triangle")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.font(.headline)
|
.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.")
|
Text(
|
||||||
.foregroundColor(.secondary)
|
"Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel."
|
||||||
.font(.caption)
|
)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} else {
|
} else {
|
||||||
Text("No window information available")
|
Text("No window information available")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(isFindingWindow ? "Searching..." : "Find Window") {
|
Button(isFindingWindow ? "Searching..." : "Find Window") {
|
||||||
findWindow()
|
findWindow()
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +192,7 @@ struct SessionDetailView: View {
|
||||||
}
|
}
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(minWidth: 400)
|
.frame(minWidth: 400)
|
||||||
|
|
@ -233,39 +238,48 @@ struct SessionDetailView: View {
|
||||||
// TODO: Implement session termination
|
// TODO: Implement session termination
|
||||||
logger.info("Terminating session \(session.id)")
|
logger.info("Terminating session \(session.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findWindow() {
|
private func findWindow() {
|
||||||
isFindingWindow = true
|
isFindingWindow = true
|
||||||
windowSearchAttempted = true
|
windowSearchAttempted = true
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer {
|
defer {
|
||||||
isFindingWindow = false
|
isFindingWindow = false
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Looking for window associated with session \(session.id)")
|
logger.info("Looking for window associated with session \(session.id)")
|
||||||
|
|
||||||
// First, check if WindowTracker already has window info for this session
|
// First, check if WindowTracker already has window info for this session
|
||||||
if let trackedWindow = WindowTracker.shared.windowInfo(for: session.id) {
|
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
|
self.windowInfo = trackedWindow
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("No tracked window found for session \(session.id), attempting to find it...")
|
logger.info("No tracked window found for session \(session.id), attempting to find it...")
|
||||||
|
|
||||||
// Get all terminal windows for debugging
|
// Get all terminal windows for debugging
|
||||||
let allWindows = WindowEnumerator.getAllTerminalWindows()
|
let allWindows = WindowEnumerator.getAllTerminalWindows()
|
||||||
logger.info("Found \(allWindows.count) terminal windows currently open")
|
logger.info("Found \(allWindows.count) terminal windows currently open")
|
||||||
|
|
||||||
// Log details about each window for debugging
|
// Log details about each window for debugging
|
||||||
for (index, window) in allWindows.enumerated() {
|
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
|
// 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
|
// Try to match by various criteria
|
||||||
if let pid = session.pid {
|
if let pid = session.pid {
|
||||||
logger.info("Looking for window with PID \(pid)...")
|
logger.info("Looking for window with PID \(pid)...")
|
||||||
|
|
@ -284,11 +298,11 @@ struct SessionDetailView: View {
|
||||||
logger.warning("No window found with PID \(pid)")
|
logger.warning("No window found with PID \(pid)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find by window title containing working directory
|
// Try to find by window title containing working directory
|
||||||
let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent
|
let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent
|
||||||
logger.info("Looking for window with title containing '\(workingDirName)'...")
|
logger.info("Looking for window with title containing '\(workingDirName)'...")
|
||||||
|
|
||||||
if let window = allWindows.first(where: { window in
|
if let window = allWindows.first(where: { window in
|
||||||
if let title = window.title {
|
if let title = window.title {
|
||||||
return title.contains(workingDirName) || title.contains(session.id)
|
return title.contains(workingDirName) || title.contains(session.id)
|
||||||
|
|
@ -306,70 +320,76 @@ struct SessionDetailView: View {
|
||||||
)
|
)
|
||||||
return
|
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")
|
logger.warning("Session may not have an associated terminal window or window detection failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func focusWindow() {
|
private func focusWindow() {
|
||||||
// Use WindowTracker's existing focus logic which handles all the complexity
|
// Use WindowTracker's existing focus logic which handles all the complexity
|
||||||
logger.info("Attempting to focus window for session \(session.id)")
|
logger.info("Attempting to focus window for session \(session.id)")
|
||||||
|
|
||||||
// First ensure we have window info
|
// First ensure we have window info
|
||||||
if windowInfo == nil {
|
if windowInfo == nil {
|
||||||
logger.info("No window info cached, trying to find window first...")
|
logger.info("No window info cached, trying to find window first...")
|
||||||
findWindow()
|
findWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let windowInfo = windowInfo {
|
if let windowInfo {
|
||||||
logger.info("Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)"
|
||||||
|
)
|
||||||
WindowTracker.shared.focusWindow(for: session.id)
|
WindowTracker.shared.focusWindow(for: session.id)
|
||||||
} else {
|
} else {
|
||||||
logger.error("Cannot focus window - no window found for session \(session.id)")
|
logger.error("Cannot focus window - no window found for session \(session.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func captureWindowScreenshot() async {
|
private func captureWindowScreenshot() async {
|
||||||
guard let windowInfo = windowInfo else {
|
guard let windowInfo else {
|
||||||
logger.warning("No window info available for screenshot")
|
logger.warning("No window info available for screenshot")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isCapturingScreenshot = true
|
isCapturingScreenshot = true
|
||||||
}
|
}
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
isCapturingScreenshot = false
|
isCapturingScreenshot = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for screen recording permission
|
// Check for screen recording permission
|
||||||
let hasPermission = await checkScreenCapturePermission()
|
let hasPermission = await checkScreenCapturePermission()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
hasScreenCapturePermission = hasPermission
|
hasScreenCapturePermission = hasPermission
|
||||||
}
|
}
|
||||||
|
|
||||||
guard hasPermission else {
|
guard hasPermission else {
|
||||||
logger.warning("No screen capture permission")
|
logger.warning("No screen capture permission")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Get available content
|
// Get available content
|
||||||
let availableContent = try await SCShareableContent.current
|
let availableContent = try await SCShareableContent.current
|
||||||
|
|
||||||
// Find the window
|
// Find the window
|
||||||
guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else {
|
guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else {
|
||||||
logger.warning("Window not found in shareable content")
|
logger.warning("Window not found in shareable content")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create content filter for this specific window
|
// Create content filter for this specific window
|
||||||
let filter = SCContentFilter(desktopIndependentWindow: window)
|
let filter = SCContentFilter(desktopIndependentWindow: window)
|
||||||
|
|
||||||
// Configure the capture
|
// Configure the capture
|
||||||
let config = SCStreamConfiguration()
|
let config = SCStreamConfiguration()
|
||||||
config.width = Int(window.frame.width * 2) // Retina resolution
|
config.width = Int(window.frame.width * 2) // Retina resolution
|
||||||
|
|
@ -377,39 +397,38 @@ struct SessionDetailView: View {
|
||||||
config.scalesToFit = true
|
config.scalesToFit = true
|
||||||
config.showsCursor = false
|
config.showsCursor = false
|
||||||
config.captureResolution = .best
|
config.captureResolution = .best
|
||||||
|
|
||||||
// Capture the screenshot
|
// Capture the screenshot
|
||||||
let screenshot = try await SCScreenshotManager.captureImage(
|
let screenshot = try await SCScreenshotManager.captureImage(
|
||||||
contentFilter: filter,
|
contentFilter: filter,
|
||||||
configuration: config
|
configuration: config
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert CGImage to NSImage
|
// Convert CGImage to NSImage
|
||||||
let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height))
|
let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height))
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.windowScreenshot = nsImage
|
self.windowScreenshot = nsImage
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Successfully captured window screenshot")
|
logger.info("Successfully captured window screenshot")
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to capture screenshot: \(error)")
|
logger.error("Failed to capture screenshot: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkScreenCapturePermission() async -> Bool {
|
private func checkScreenCapturePermission() async -> Bool {
|
||||||
// Check if we have screen recording permission
|
// Check if we have screen recording permission
|
||||||
let hasPermission = CGPreflightScreenCaptureAccess()
|
let hasPermission = CGPreflightScreenCaptureAccess()
|
||||||
|
|
||||||
if !hasPermission {
|
if !hasPermission {
|
||||||
// Request permission
|
// Request permission
|
||||||
return CGRequestScreenCaptureAccess()
|
return CGRequestScreenCaptureAccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openScreenRecordingSettings() {
|
private func openScreenRecordingSettings() {
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@ struct AdvancedSettingsView: View {
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window Highlight section
|
||||||
|
WindowHighlightSettingsSection()
|
||||||
|
|
||||||
// Advanced section
|
// Advanced section
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -345,7 +348,7 @@ private struct TerminalPreferenceSection: View {
|
||||||
|
|
||||||
private var gitAppBinding: Binding<String> {
|
private var gitAppBinding: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
// If no preference or invalid preference, use first installed app
|
// If no preference or invalid preference, use first installed app
|
||||||
if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil {
|
if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil {
|
||||||
return GitApp.installed.first?.rawValue ?? ""
|
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
|
private var debugMode = false
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
static let defaultTabSize = CGSize(width: 500, height: 620)
|
static let defaultTabSize = CGSize(width: 500, height: 620)
|
||||||
static let fallbackTabSize = CGSize(width: 500, height: 400)
|
static let fallbackTabSize = CGSize(width: 500, height: 400)
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,12 @@ struct VTCommandPageView: View {
|
||||||
.frame(maxWidth: 480)
|
.frame(maxWidth: 480)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text("vt claude")
|
Text("vt claude or vt gemini")
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
|
||||||
|
|
@ -644,6 +644,8 @@ final class TerminalLauncher {
|
||||||
activate
|
activate
|
||||||
set newWindow to (create window with default profile)
|
set newWindow to (create window with default profile)
|
||||||
tell current session of newWindow
|
tell current session of newWindow
|
||||||
|
-- Set session name to include session ID for easier matching
|
||||||
|
set name to "Session \(sessionId)"
|
||||||
write text "\(config.appleScriptEscapedCommand)"
|
write text "\(config.appleScriptEscapedCommand)"
|
||||||
end tell
|
end tell
|
||||||
return id of newWindow
|
return id of newWindow
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,35 @@ export class SessionView extends LitElement {
|
||||||
if (this.session && sessionId === this.session.id) {
|
if (this.session && sessionId === this.session.id) {
|
||||||
this.session = { ...this.session, status: 'exited' };
|
this.session = { ...this.session, status: 'exited' };
|
||||||
this.requestUpdate();
|
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) => {
|
(session: Session) => {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface StreamConnection {
|
||||||
eventSource: EventSource;
|
eventSource: EventSource;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
errorHandler?: EventListener;
|
errorHandler?: EventListener;
|
||||||
|
sessionExitHandler?: EventListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
|
|
@ -72,6 +73,20 @@ export class ConnectionManager {
|
||||||
// Use CastConverter to connect terminal to stream with reconnection tracking
|
// Use CastConverter to connect terminal to stream with reconnection tracking
|
||||||
const connection = CastConverter.connectToStream(this.terminal, streamUrl);
|
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
|
// Wrap the connection to track reconnections
|
||||||
const originalEventSource = connection.eventSource;
|
const originalEventSource = connection.eventSource;
|
||||||
let lastErrorTime = 0;
|
let lastErrorTime = 0;
|
||||||
|
|
@ -114,16 +129,23 @@ export class ConnectionManager {
|
||||||
// Override the error handler
|
// Override the error handler
|
||||||
originalEventSource.addEventListener('error', handleError);
|
originalEventSource.addEventListener('error', handleError);
|
||||||
|
|
||||||
// Store the connection with error handler reference
|
// Store the connection with error handler reference and session-exit handler
|
||||||
this.streamConnection = {
|
this.streamConnection = {
|
||||||
...connection,
|
...connection,
|
||||||
errorHandler: handleError as EventListener,
|
errorHandler: handleError as EventListener,
|
||||||
|
sessionExitHandler: handleSessionExit as EventListener,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupStreamConnection(): void {
|
cleanupStreamConnection(): void {
|
||||||
if (this.streamConnection) {
|
if (this.streamConnection) {
|
||||||
logger.log('Cleaning up stream connection');
|
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.disconnect();
|
||||||
this.streamConnection = null;
|
this.streamConnection = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
process.exit(0);
|
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(' ')}`);
|
logger.debug(`Full command: ${args.join(' ')}`);
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
|
|
@ -195,7 +195,7 @@ export async function startVibeTunnelForward(args: string[]) {
|
||||||
const sent = socketClient.updateTitle(sanitizedTitle);
|
const sent = socketClient.updateTitle(sanitizedTitle);
|
||||||
|
|
||||||
if (sent) {
|
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
|
// IPC update succeeded, server will handle the file update
|
||||||
socketClient.disconnect();
|
socketClient.disconnect();
|
||||||
closeLogger();
|
closeLogger();
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,10 @@ export class PtyManager extends EventEmitter {
|
||||||
session.stdoutQueue = stdoutQueue;
|
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
|
// Setup activity detector for dynamic mode
|
||||||
if (session.titleMode === TitleMode.DYNAMIC) {
|
if (session.titleMode === TitleMode.DYNAMIC) {
|
||||||
session.activityDetector = new ActivityDetector(session.sessionInfo.command);
|
session.activityDetector = new ActivityDetector(session.sessionInfo.command);
|
||||||
|
|
@ -691,11 +695,15 @@ export class PtyManager extends EventEmitter {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case MessageType.STDIN_DATA: {
|
case MessageType.STDIN_DATA: {
|
||||||
const text = data as string;
|
const text = data as string;
|
||||||
if (session.ptyProcess) {
|
if (session.ptyProcess && session.inputQueue) {
|
||||||
// Write input first for fastest response
|
// Queue input write to prevent race conditions
|
||||||
session.ptyProcess.write(text);
|
session.inputQueue.enqueue(() => {
|
||||||
// Then record it (non-blocking)
|
if (session.ptyProcess) {
|
||||||
session.asciinemaWriter?.writeInput(text);
|
session.ptyProcess.write(text);
|
||||||
|
}
|
||||||
|
// Record it (non-blocking)
|
||||||
|
session.asciinemaWriter?.writeInput(text);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -793,26 +801,31 @@ export class PtyManager extends EventEmitter {
|
||||||
|
|
||||||
// If we have an in-memory session with active PTY, use it
|
// If we have an in-memory session with active PTY, use it
|
||||||
const memorySession = this.sessions.get(sessionId);
|
const memorySession = this.sessions.get(sessionId);
|
||||||
if (memorySession?.ptyProcess) {
|
if (memorySession?.ptyProcess && memorySession.inputQueue) {
|
||||||
memorySession.ptyProcess.write(dataToSend);
|
// Queue input write to prevent race conditions
|
||||||
memorySession.asciinemaWriter?.writeInput(dataToSend);
|
memorySession.inputQueue.enqueue(() => {
|
||||||
|
if (memorySession.ptyProcess) {
|
||||||
// Track directory changes for title modes that need it
|
memorySession.ptyProcess.write(dataToSend);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
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
|
return; // Important: return here to avoid socket path
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export interface PtySession {
|
||||||
// Optional fields for resource cleanup
|
// Optional fields for resource cleanup
|
||||||
inputSocketServer?: net.Server;
|
inputSocketServer?: net.Server;
|
||||||
stdoutQueue?: WriteQueue;
|
stdoutQueue?: WriteQueue;
|
||||||
|
inputQueue?: WriteQueue;
|
||||||
// Terminal title mode
|
// Terminal title mode
|
||||||
titleMode?: TitleMode;
|
titleMode?: TitleMode;
|
||||||
// Track current working directory for title updates
|
// Track current working directory for title updates
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue