Add magic wand button to inject prompts into Claude sessions (#210)

This commit is contained in:
Peter Steinberger 2025-07-03 23:57:30 +01:00 committed by GitHub
parent 359824a56f
commit d3d0ce9dde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1997 additions and 269 deletions

View file

@ -1,5 +1,42 @@
# Changelog
## [1.0.0-beta.7] - 2025-07-03
### ✨ New Features
**Magic Wand for AI Assistants**
- **Instant Terminal Title Updates** - New magic wand button (🪄) appears when hovering over AI assistant sessions
- **Universal AI Support** - Works with Claude, Gemini, GPT, and other AI command-line tools - not just Claude anymore
- **One-Click Status Updates** - Clicking the wand prompts your AI assistant to update the terminal title with what it's currently working on
- **Smart Detection** - Automatically detects AI sessions by recognizing common command names
**Window Highlight Live Preview**
- **See Before You Save** - Window highlight settings now show a live preview on the Settings window itself
- **Instant Feedback** - Preview updates immediately as you change highlight styles or colors
- **Try Before Apply** - Test different highlight styles (Default, Subtle, Neon, Custom) without leaving settings
**Enhanced Terminal Support**
- **Ghostty Integration** - Added full support for Ghostty terminal - windows now close automatically when sessions end
- **Complete Terminal Coverage** - VibeTunnel now supports automatic window management for Terminal.app, iTerm2, and Ghostty
### 🐛 Bug Fixes
**Window Management**
- **Accurate Window Focus** - Fixed issues where the wrong terminal window would be highlighted when switching sessions
- **Better Multi-Display Support** - Window highlights now position correctly on external monitors
- **Consistent Green Highlights** - Changed highlight color from purple to green to match the web interface
- **Auto-Close Fixed** - Terminal windows now properly close when sessions exit naturally (not just when manually stopped)
**Magic Wand Reliability**
- **Proper Command Submission** - Fixed magic wand commands not being executed properly - now sends both the prompt and Enter key correctly
- **No More Race Conditions** - Added input queue protection to ensure commands are sent in the correct order
- **Works Every Time** - Magic wand prompts now reliably trigger terminal title updates
**General Improvements**
- **Quieter Logs** - Reduced verbosity of terminal title update logs - less noise in debug output
- **Swift 6 Compatibility** - Fixed all concurrency and syntax errors for latest Swift compiler
- **Cleaner UI** - Reorganized settings to put Window Highlight options in a more logical location
## [1.0.0-beta.5] - upcoming
### 🎯 Features

View file

@ -1,8 +1,8 @@
// VibeTunnel Version Configuration
// This file contains the version and build number for the app
MARKETING_VERSION = 1.0.0-beta.6
CURRENT_PROJECT_VERSION = 152
MARKETING_VERSION = 1.0.0-beta.7
CURRENT_PROJECT_VERSION = 160
// Domain and GitHub configuration
APP_DOMAIN = vibetunnel.sh

View 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)
}
}

View 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
}
}

View file

@ -50,6 +50,21 @@ final class SessionService {
}
/// Terminate a session
///
/// This method performs a two-step termination process:
/// 1. Sends a DELETE request to the server to kill the process
/// 2. Closes the terminal window if it was opened by VibeTunnel
///
/// The window closing step is crucial for user experience - it prevents
/// the accumulation of empty terminal windows after killing processes.
/// However, it only closes windows that VibeTunnel opened via AppleScript,
/// not windows from external `vt` attachments.
///
/// - Parameter sessionId: The ID of the session to terminate
/// - Throws: `SessionServiceError` if the termination request fails
///
/// - Note: The server implements graceful termination (SIGTERM SIGKILL)
/// with a 3-second timeout before force-killing processes.
func terminateSession(sessionId: String) async throws {
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else {
throw SessionServiceError.invalidURL
@ -68,9 +83,79 @@ final class SessionService {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
// After successfully terminating the session, close the window if we opened it.
// This is the key feature that prevents orphaned terminal windows.
//
// Why this matters:
// - Simple commands (like `ls`) exit naturally and close their windows
// - Long-running processes (like `claude`) leave windows open when killed
// - This ensures consistent behavior - windows always close when sessions end
//
// The check inside closeWindowIfOpenedByUs ensures we only close windows
// that VibeTunnel created, not externally attached sessions.
_ = await MainActor.run {
WindowTracker.shared.closeWindowIfOpenedByUs(for: sessionId)
}
// The session monitor will automatically update via its polling mechanism
}
/// Send input text to a session
func sendInput(to sessionId: String, text: String) async throws {
guard serverManager.isRunning else {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
try serverManager.authenticate(request: &request)
let body = ["text": text]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 || httpResponse.statusCode == 204
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
}
/// Send a key command to a session
func sendKey(to sessionId: String, key: String) async throws {
guard serverManager.isRunning else {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
try serverManager.authenticate(request: &request)
let body = ["key": key]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 || httpResponse.statusCode == 204
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
}
/// Create a new session
func createSession(
command: [String],

View file

@ -9,6 +9,29 @@ import OSLog
/// - Map VibeTunnel sessions to their terminal windows
/// - Focus specific terminal windows when requested
/// - Handle both windows and tabs for different terminal applications
/// - **Close terminal windows when sessions are terminated (NEW)**
///
/// ## Window Closing Feature
///
/// A key enhancement is the ability to automatically close terminal windows when
/// their associated sessions are terminated. This solves the common problem where
/// killing a long-running process (like `claude`) leaves an empty terminal window.
///
/// ### Design Principles:
/// 1. **Only close what we open**: Windows are only closed if VibeTunnel opened them
/// 2. **Track ownership at creation**: Sessions opened via AppleScript are marked at launch time
/// 3. **Respect external sessions**: Sessions attached via `vt` are never closed
///
/// ### Implementation:
/// - When spawning terminals via AppleScript, sessions are marked in `sessionsOpenedByUs` set
/// - On termination, we dynamically find windows using process tree traversal
/// - Only windows for sessions in the set are closed
/// - Currently supports Terminal.app and iTerm2
///
/// ### User Experience:
/// - Consistent behavior: All VibeTunnel-spawned windows close on termination
/// - No orphaned windows: Prevents accumulation of empty terminals
/// - External sessions preserved: `vt`-attached terminals remain open
@MainActor
final class WindowTracker {
static let shared = WindowTracker()
@ -21,6 +44,24 @@ final class WindowTracker {
/// Maps session IDs to their terminal window information
private var sessionWindowMap: [String: WindowEnumerator.WindowInfo] = [:]
/// Tracks which sessions we opened via AppleScript (and can close).
///
/// When VibeTunnel spawns a terminal session through AppleScript, we mark
/// it in this set. This allows us to distinguish between:
/// - Sessions we created: Can and should close their windows
/// - Sessions attached via `vt`: Should never close their windows
///
/// The actual window finding happens dynamically using process tree traversal,
/// making the system robust against tab reordering and window manipulation.
///
/// Example flow:
/// 1. User creates session via UI TerminalLauncher uses AppleScript
/// 2. Session ID is added to this set
/// 3. User kills session We find and close the window dynamically
///
/// Sessions attached via `vt` command are NOT added to this set.
private var sessionsOpenedByUs: Set<String> = []
/// Lock for thread-safe access to the session map
private let mapLock = NSLock()
@ -37,70 +78,37 @@ final class WindowTracker {
// MARK: - Window Registration
/// Registers a terminal window for a session.
/// Registers a session that was opened by VibeTunnel.
/// This should be called after launching a terminal with a session ID.
/// Only sessions registered here will have their windows closed on termination.
func registerSessionOpenedByUs(
for sessionID: String,
terminalApp: Terminal
) {
logger.info("Registering session opened by us: \(sessionID), terminal: \(terminalApp.rawValue)")
// Mark this session as opened by us, so we can close its window later
// This is the critical point where we distinguish between:
// - Sessions we created via AppleScript (can close)
// - Sessions attached via `vt` command (cannot close)
_ = mapLock.withLock {
sessionsOpenedByUs.insert(sessionID)
}
// Window finding is now handled dynamically when needed (focus/close)
// This avoids storing stale tab references
}
/// Legacy method for compatibility - redirects to simplified registration
func registerWindow(
for sessionID: String,
terminalApp: Terminal,
tabReference: String? = nil,
tabID: String? = nil
) {
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
// For Terminal.app and iTerm2 with explicit window/tab info, register immediately
if (terminalApp == .terminal && tabReference != nil) ||
(terminalApp == .iTerm2 && tabID != nil)
{
// These terminals provide explicit window/tab IDs, so we can register immediately
Task {
try? await Task.sleep(for: .milliseconds(500))
if let windowInfo = findWindow(
for: terminalApp,
sessionID: sessionID,
tabReference: tabReference,
tabID: tabID
) {
mapLock.withLock {
sessionWindowMap[sessionID] = windowInfo
}
logger
.info(
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) with explicit ID"
)
}
}
return
}
// For other terminals, use progressive delays to find the window
Task {
// Try multiple times with increasing delays
let delays: [Double] = [0.5, 1.0, 2.0, 3.0]
for (index, delay) in delays.enumerated() {
try? await Task.sleep(for: .seconds(delay))
// Try to find the window
if let windowInfo = findWindow(
for: terminalApp,
sessionID: sessionID,
tabReference: tabReference,
tabID: tabID
) {
mapLock.withLock {
sessionWindowMap[sessionID] = windowInfo
}
logger
.info(
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) after \(index + 1) attempts"
)
return
}
}
logger.warning("Failed to register window for session \(sessionID) after all attempts")
}
// Simply mark the session as opened by us
// We no longer store tab references as they become stale
registerSessionOpenedByUs(for: sessionID, terminalApp: terminalApp)
}
/// Unregisters a window for a session.
@ -109,6 +117,7 @@ final class WindowTracker {
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
logger.info("Unregistered window for session: \(sessionID)")
}
sessionsOpenedByUs.remove(sessionID)
}
}
@ -148,6 +157,153 @@ final class WindowTracker {
windowFocuser.focusWindow(windowInfo)
}
// MARK: - Window Closing
/// Closes the terminal window for a specific session if it was opened by VibeTunnel.
///
/// This method implements a key feature where terminal windows are automatically closed
/// when their associated sessions are terminated, but ONLY if VibeTunnel opened them.
/// This prevents the common issue where killing a process leaves empty terminal windows.
///
/// The method checks if:
/// 1. The session was opened by VibeTunnel (exists in `sessionsOpenedByUs`)
/// 2. We can find the window using dynamic lookup (process tree traversal)
/// 3. We can close via Accessibility API (PID-based) or AppleScript
///
/// - Parameter sessionID: The ID of the session whose window should be closed
/// - Returns: `true` if the window was successfully closed, `false` otherwise
///
/// - Note: This is called automatically by `SessionService.terminateSession()`
/// after the server confirms the process has been killed.
///
/// Example scenarios:
/// - User runs `claude` command via UI Window closes when session killed
/// - User runs long process via UI Window closes when session killed
/// - User attaches existing terminal via `vt` Window NOT closed
/// - User manually opens terminal Window NOT closed
@discardableResult
func closeWindowIfOpenedByUs(for sessionID: String) -> Bool {
// Check if we opened this window
let wasOpenedByUs = mapLock.withLock {
sessionsOpenedByUs.contains(sessionID)
}
guard wasOpenedByUs else {
logger.info("Session \(sessionID) was not opened by VibeTunnel, not closing window")
return false
}
// Use dynamic lookup to find the window
// This is more reliable than stored references which can become stale
guard let sessionInfo = getSessionInfo(for: sessionID) else {
logger.warning("No session info found for session: \(sessionID)")
unregisterWindow(for: sessionID)
return false
}
guard let windowInfo = findWindowForSession(sessionID, sessionInfo: sessionInfo) else {
logger.warning("Could not find window for session \(sessionID) - it may have been closed already")
// Clean up tracking since window is gone
unregisterWindow(for: sessionID)
return false
}
logger.info("Closing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue)")
// Generate and execute AppleScript to close the window
let closeScript = generateCloseWindowScript(for: windowInfo)
do {
try AppleScriptExecutor.shared.execute(closeScript)
logger.info("Successfully closed window for session: \(sessionID)")
// Clean up tracking
unregisterWindow(for: sessionID)
return true
} catch {
logger.error("Failed to close window for session \(sessionID): \(error)")
return false
}
}
/// Generates AppleScript to close a specific terminal window.
///
/// This method creates terminal-specific AppleScript commands to close windows.
/// Uses window IDs from dynamic lookup rather than stored tab references,
/// making it robust against tab reordering and window manipulation.
///
/// - **Terminal.app**: Uses window ID to close the entire window
/// - `saving no` prevents save dialogs
/// - Closes all tabs in the window
///
/// - **iTerm2**: Uses window ID with robust matching
/// - Iterates through windows to find exact match
/// - Closes entire window
///
/// - **Ghostty**: Uses standard AppleScript window closing
/// - Directly closes window by ID
/// - Supports modern window management
///
/// - **Other terminals**: Not supported as they don't provide reliable window IDs
///
/// - Parameter windowInfo: Window information from dynamic lookup
/// - Returns: AppleScript string to close the window, or empty string if unsupported
///
/// - Note: All scripts include error handling to gracefully handle already-closed windows
private func generateCloseWindowScript(for windowInfo: WindowEnumerator.WindowInfo) -> String {
switch windowInfo.terminalApp {
case .terminal:
// Use window ID to close - more reliable than tab references
return """
tell application "Terminal"
try
close (first window whose id is \(windowInfo.windowID)) saving no
on error
-- Window might already be closed
end try
end tell
"""
case .iTerm2:
// For iTerm2, close the window by matching against all windows
// iTerm2's window IDs can be tricky, so we use a more robust approach
return """
tell application "iTerm2"
try
set targetWindows to (windows)
repeat with w in targetWindows
try
if id of w is \(windowInfo.windowID) then
close w
exit repeat
end if
end try
end repeat
on error
-- Window might already be closed
end try
end tell
"""
case .ghostty:
// Ghostty supports standard AppleScript window operations
// Note: Ghostty uses lowercase "ghostty" in System Events
return """
tell application "ghostty"
try
close (first window whose id is \(windowInfo.windowID))
on error
-- Window might already be closed
end try
end tell
"""
default:
// For other terminals, we don't have reliable window closing
logger.warning("Cannot close window for \(windowInfo.terminalApp.rawValue) - terminal not supported")
return ""
}
}
// MARK: - Permission Management
/// Check if we have the required permissions.
@ -178,6 +334,17 @@ final class WindowTracker {
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
logger.info("Removed window tracking for terminated session: \(sessionID)")
}
// Also clean up the opened-by-us tracking
sessionsOpenedByUs.remove(sessionID)
}
}
// Check for sessions that have exited and close their windows if we opened them
for session in sessions where session.status == "exited" {
// Only close windows that we opened (not external vt attachments)
if sessionsOpenedByUs.contains(session.id) {
logger.info("Session \(session.id) has exited naturally, closing its window")
_ = closeWindowIfOpenedByUs(for: session.id)
}
}

View file

@ -11,6 +11,72 @@ final class WindowFocuser {
)
private let windowMatcher = WindowMatcher()
private let highlightEffect: WindowHighlightEffect
init() {
// Load configuration from UserDefaults
let config = Self.loadHighlightConfig()
self.highlightEffect = WindowHighlightEffect(config: config)
// Observe UserDefaults changes
NotificationCenter.default.addObserver(
self,
selector: #selector(userDefaultsDidChange),
name: UserDefaults.didChangeNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Load highlight configuration from UserDefaults
private static func loadHighlightConfig() -> WindowHighlightConfig {
let defaults = UserDefaults.standard
let isEnabled = defaults.object(forKey: "windowHighlightEnabled") as? Bool ?? true
let style = defaults.string(forKey: "windowHighlightStyle") ?? "default"
guard isEnabled else {
return WindowHighlightConfig(
color: .clear,
duration: 0,
borderWidth: 0,
glowRadius: 0,
isEnabled: false
)
}
switch style {
case "subtle":
return .subtle
case "neon":
return .neon
case "custom":
// Load custom color
let colorData = defaults.data(forKey: "windowHighlightColor") ?? Data()
if !colorData.isEmpty,
let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) {
return WindowHighlightConfig(
color: nsColor,
duration: 0.8,
borderWidth: 4.0,
glowRadius: 12.0,
isEnabled: true
)
}
return .default
default:
return .default
}
}
/// Handle UserDefaults changes
@objc private func userDefaultsDidChange(_ notification: Notification) {
// Update highlight configuration when settings change
let newConfig = Self.loadHighlightConfig()
highlightEffect.updateConfig(newConfig)
}
/// Focus a window based on terminal type
func focusWindow(_ windowInfo: WindowEnumerator.WindowInfo) {
@ -129,29 +195,20 @@ final class WindowFocuser {
}
/// Get the first tab group in a window (improved approach based on screenshot)
private func getTabGroup(from window: AXUIElement) -> AXUIElement? {
var childrenRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
window,
kAXChildrenAttribute as CFString,
&childrenRef
) == .success,
let children = childrenRef as? [AXUIElement]
else {
private func getTabGroup(from window: AXElement) -> AXElement? {
guard let children = window.children else {
return nil
}
// Find the first element with role kAXTabGroupRole
return children.first { elem in
var roleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
return (roleRef as? String) == kAXTabGroupRole as String
elem.role == kAXTabGroupRole
}
}
/// Select the correct tab in a window that uses macOS standard tabs
private func selectTab(
tabs: [AXUIElement],
tabs: [AXElement],
windowInfo: WindowEnumerator.WindowInfo,
sessionInfo: ServerSessionInfo?
) {
@ -160,22 +217,14 @@ final class WindowFocuser {
// Try to find the correct tab
if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) {
// Found matching tab - select it using kAXPressAction (most reliable)
let result = AXUIElementPerformAction(matchingTab, kAXPressAction as CFString)
if result == .success {
if matchingTab.press() {
logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)")
} else {
logger.warning("Failed to select tab with kAXPressAction, error: \(result.rawValue)")
logger.warning("Failed to select tab with kAXPressAction")
// Try alternative selection method - set as selected
var selectedValue: CFTypeRef?
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) ==
.success
{
let setResult = AXUIElementSetAttributeValue(
matchingTab,
kAXSelectedAttribute as CFString,
true as CFTypeRef
)
if matchingTab.isAttributeSettable(kAXSelectedAttribute) {
let setResult = matchingTab.setSelected(true)
if setResult == .success {
logger.info("Selected tab using AXSelected attribute")
} else {
@ -185,7 +234,7 @@ final class WindowFocuser {
}
} else if tabs.count == 1 {
// If only one tab, select it
AXUIElementPerformAction(tabs[0], kAXPressAction as CFString)
tabs[0].press()
logger.info("Selected the only available tab")
} else {
// Multiple tabs but no match - try to find by index or select first
@ -196,10 +245,7 @@ final class WindowFocuser {
// Log tab titles for debugging
for (index, tab) in tabs.enumerated() {
var titleValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
let title = titleValue as? String
{
if let title = tab.title {
logger.debug(" Tab \(index): \(title)")
}
}
@ -207,73 +253,255 @@ final class WindowFocuser {
}
/// Select a tab by index in a tab group (helper method from screenshot)
private func selectTab(at index: Int, in group: AXUIElement) -> Bool {
var tabsRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
group,
"AXTabs" as CFString,
&tabsRef
) == .success,
let tabs = tabsRef as? [AXUIElement],
index < tabs.count
private func selectTab(at index: Int, in group: AXElement) -> Bool {
guard let tabs = group.tabs,
index < tabs.count
else {
logger.warning("Could not get tabs from group or index out of bounds")
return false
}
return tabs[index].press()
}
let result = AXUIElementPerformAction(tabs[index], kAXPressAction as CFString)
return result == .success
/// Focuses a window by using the process PID directly
private func focusWindowUsingPID(_ windowInfo: WindowEnumerator.WindowInfo) -> Bool {
// Get session info for better matching
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
// Create AXElement directly from the PID
let axProcess = AXElement.application(pid: windowInfo.ownerPID)
// Get windows from this specific process
guard let windows = axProcess.windows,
!windows.isEmpty
else {
logger.debug("PID-based lookup failed for PID \(windowInfo.ownerPID), no windows found")
return false
}
logger.info("Found \(windows.count) window(s) for PID \(windowInfo.ownerPID)")
// Single window case - simple!
if windows.count == 1 {
logger.info("Single window found for PID \(windowInfo.ownerPID), focusing it directly")
let window = windows[0]
// Show highlight effect
highlightEffect.highlightWindow(window, bounds: window.frame())
// Focus the window
window.setMain(true)
window.setFocused(true)
// Bring app to front
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
app.activate()
}
return true
}
// Multiple windows - need to be smarter
logger.info("Multiple windows found for PID \(windowInfo.ownerPID), using scoring system")
// Use our existing scoring logic but only on these PID-specific windows
var bestMatch: (window: AXElement, score: Int)?
for (index, window) in windows.enumerated() {
var matchScore = 0
// Check window title for session ID or working directory (most reliable)
if let title = window.title {
logger.debug("Window \(index) title: '\(title)'")
// Check for session ID in title
if title.contains(windowInfo.sessionID) || title.contains("TTY_SESSION_ID=\(windowInfo.sessionID)") {
matchScore += 200 // Highest score for session ID match
logger.debug("Window \(index) has session ID in title!")
}
// Check for working directory in title
if let sessionInfo = sessionInfo {
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
if !dirName.isEmpty && (title.contains(dirName) || title.hasSuffix(dirName) || title.hasSuffix(" - \(dirName)")) {
matchScore += 100 // High score for directory match
logger.debug("Window \(index) has working directory in title: \(dirName)")
}
// Check for session name
if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) {
matchScore += 150 // High score for session name match
logger.debug("Window \(index) has session name in title: \(sessionName)")
}
}
}
// Check window ID (less reliable for terminals)
if let axWindowID = window.windowID {
if axWindowID == windowInfo.windowID {
matchScore += 50 // Lower score since window IDs can be unreliable
logger.debug("Window \(index) has matching ID: \(axWindowID)")
}
}
// Check bounds if available (least reliable as windows can move)
if let bounds = windowInfo.bounds,
let windowFrame = window.frame() {
let tolerance: CGFloat = 5.0
if abs(windowFrame.origin.x - bounds.origin.x) < tolerance &&
abs(windowFrame.origin.y - bounds.origin.y) < tolerance &&
abs(windowFrame.width - bounds.width) < tolerance &&
abs(windowFrame.height - bounds.height) < tolerance
{
matchScore += 25 // Lowest score for bounds match
logger.debug("Window \(index) bounds match")
}
}
if matchScore > 0 && (bestMatch == nil || matchScore > bestMatch!.score) {
bestMatch = (window, matchScore)
}
}
if let best = bestMatch {
logger.info("Focusing best match window with score \(best.score) for PID \(windowInfo.ownerPID)")
// Show highlight effect
highlightEffect.highlightWindow(best.window, bounds: best.window.frame())
// Focus the window
best.window.setMain(true)
best.window.setFocused(true)
// Bring app to front
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
app.activate()
}
return true
}
logger.error("No matching window found for PID \(windowInfo.ownerPID)")
return false
}
/// Focuses a window using Accessibility APIs.
private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) {
// First try PID-based approach
if focusWindowUsingPID(windowInfo) {
logger.info("Successfully focused window using PID-based approach")
return
}
// Fallback to the original approach if PID-based fails
logger.info("Falling back to terminal app-based window search")
// First bring the application to front
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
app.activate()
logger.info("Activated application with PID: \(windowInfo.ownerPID)")
}
// Use AXUIElement to focus the specific window
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
// Use AXElement to focus the specific window
let axApp = AXElement.application(pid: windowInfo.ownerPID)
var windowsValue: CFTypeRef?
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
guard result == .success,
let windows = windowsValue as? [AXUIElement],
guard let windows = axApp.windows,
!windows.isEmpty
else {
logger.error("Failed to get windows for application")
return
}
logger.debug("Found \(windows.count) windows for \(windowInfo.terminalApp.rawValue)")
logger
.info(
"Found \(windows.count) windows for \(windowInfo.terminalApp.rawValue), looking for window ID: \(windowInfo.windowID)"
)
// Get session info for tab matching
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
// First, try to find window with matching tab content
var foundWindowWithTab = false
var bestMatchWindow: (window: AXElement, score: Int)?
for (index, window) in windows.enumerated() {
// Check different window ID attributes (different apps use different ones)
var matchScore = 0
var windowMatches = false
// Try _AXWindowNumber (used by many apps)
var windowIDValue: CFTypeRef?
if AXUIElementCopyAttributeValue(window, "_AXWindowNumber" as CFString, &windowIDValue) == .success,
let axWindowID = windowIDValue as? Int
// Try window ID attribute for matching
if let axWindowID = window.windowID {
if axWindowID == windowInfo.windowID {
windowMatches = true
matchScore += 100 // High score for exact ID match
}
logger
.debug(
"Window \(index) windowID: \(axWindowID), target: \(windowInfo.windowID), matches: \(windowMatches)"
)
}
// Check window position and size as secondary validation
if let bounds = windowInfo.bounds,
let windowFrame = window.frame()
{
windowMatches = (axWindowID == windowInfo.windowID)
logger.debug("Window \(index) _AXWindowNumber: \(axWindowID), matches: \(windowMatches)")
// Check if bounds approximately match (within 5 pixels tolerance)
let tolerance: CGFloat = 5.0
if abs(windowFrame.origin.x - bounds.origin.x) < tolerance &&
abs(windowFrame.origin.y - bounds.origin.y) < tolerance &&
abs(windowFrame.width - bounds.width) < tolerance &&
abs(windowFrame.height - bounds.height) < tolerance
{
matchScore += 50 // Medium score for bounds match
logger
.debug(
"Window \(index) bounds match! Position: (\(windowFrame.origin.x), \(windowFrame.origin.y)), Size: (\(windowFrame.width), \(windowFrame.height))"
)
}
}
// Check window title for session information
if let title = window.title {
logger.debug("Window \(index) title: '\(title)'")
// Check for session ID in title (most reliable)
if title.contains(windowInfo.sessionID) || title.contains("TTY_SESSION_ID=\(windowInfo.sessionID)") {
matchScore += 200 // Highest score
logger.debug("Window \(index) has session ID in title!")
}
// Check for session-specific information
if let sessionInfo = sessionInfo {
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
if !dirName.isEmpty && (title.contains(dirName) || title.hasSuffix(dirName)) {
matchScore += 100
logger.debug("Window \(index) has working directory in title")
}
if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) {
matchScore += 150
logger.debug("Window \(index) has session name in title")
}
}
// Original title match logic as fallback
if !title.isEmpty && (windowInfo.title?.contains(title) ?? false || title.contains(windowInfo.title ?? "")) {
matchScore += 25 // Low score for title match
}
}
// Keep track of best match
if matchScore > 0 && (bestMatchWindow == nil || matchScore > bestMatchWindow!.score) {
bestMatchWindow = (window, matchScore)
logger.debug("Window \(index) is new best match with score: \(matchScore)")
}
// Try the improved approach: get tab group first
if let tabGroup = getTabGroup(from: window) {
// Get tabs from the tab group
var tabsValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tabGroup, "AXTabs" as CFString, &tabsValue) == .success,
let tabs = tabsValue as? [AXUIElement],
if let tabs = tabGroup.tabs,
!tabs.isEmpty
{
logger.info("Window \(index) has tab group with \(tabs.count) tabs")
@ -283,24 +511,22 @@ final class WindowFocuser {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Show highlight effect
highlightEffect.highlightWindow(window, bounds: window.frame())
// Make window main and focused
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
window.setMain(true)
window.setFocused(true)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
return
}
}
} else {
// Fallback: Try direct tabs attribute (older approach)
var tabsValue: CFTypeRef?
let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue)
if hasTabsResult == .success,
let tabs = tabsValue as? [AXUIElement],
if let tabs = window.tabs,
!tabs.isEmpty
{
logger.info("Window \(index) has \(tabs.count) tabs (direct attribute)")
@ -310,36 +536,59 @@ final class WindowFocuser {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Show highlight effect
highlightEffect.highlightWindow(window, bounds: window.frame())
// Make window main and focused
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
window.setMain(true)
window.setFocused(true)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
return
}
} else if windowMatches {
// Window matches by ID but has no tabs (or tabs not accessible)
logger.info("Window \(index) matches by ID but has no accessible tabs")
// Focus the window anyway
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
logger.info("Focused window \(windowInfo.windowID) without tab selection")
return
}
}
}
// If we didn't find a window with matching tab, just focus the first window
if !foundWindowWithTab && !windows.isEmpty {
logger.warning("No window found with matching tab, focusing first window")
let firstWindow = windows[0]
AXUIElementSetAttributeValue(firstWindow, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(firstWindow, kAXFocusedAttribute as CFString, true as CFTypeRef)
// After checking all windows, use the best match if we found one
if let bestMatch = bestMatchWindow {
logger.info("Using best match window with score \(bestMatch.score) for window ID \(windowInfo.windowID)")
// Show highlight effect
highlightEffect.highlightWindow(bestMatch.window, bounds: bestMatch.window.frame())
// Focus the best matching window
bestMatch.window.setMain(true)
bestMatch.window.setFocused(true)
// Try to select tab if available
if sessionInfo != nil {
// Try to get tabs and select the right one
if let tabGroup = getTabGroup(from: bestMatch.window) {
if let tabs = tabGroup.tabs,
!tabs.isEmpty
{
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
}
} else {
// Try direct tabs attribute
if let tabs = bestMatch.window.tabs,
!tabs.isEmpty
{
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
}
}
}
logger.info("Focused best match window for session \(windowInfo.sessionID)")
} else {
// No match found at all - log error but don't focus random window
logger
.error(
"Failed to find window with ID \(windowInfo.windowID) for session \(windowInfo.sessionID). No windows matched by ID, position, or title."
)
}
}
}

View file

@ -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()
}
}

View file

@ -37,12 +37,28 @@ final class WindowMatcher {
if let parentPID = processTracker.getParentProcessID(of: pid_t(sessionPID)) {
logger.debug("Found parent process PID: \(parentPID)")
// Look for a window owned by the parent process
if let matchingWindow = filteredWindows.first(where: { window in
// Look for windows owned by the parent process
let parentPIDWindows = filteredWindows.filter { window in
window.ownerPID == parentPID
}) {
logger.info("Found window by parent process match: PID \(parentPID)")
return matchingWindow
}
if parentPIDWindows.count == 1 {
logger.info("Found single window by parent process match: PID \(parentPID)")
return parentPIDWindows.first
} else if parentPIDWindows.count > 1 {
logger.info("Found \(parentPIDWindows.count) windows for PID \(parentPID), checking session ID in titles")
// Multiple windows - try to match by session ID in title
if let matchingWindow = parentPIDWindows.first(where: { window in
window.title?.contains("Session \(sessionID)") ?? false
}) {
logger.info("Found window by session ID '\(sessionID)' in title")
return matchingWindow
}
// If no session ID match, return first window
logger.warning("No window with session ID in title, using first window")
return parentPIDWindows.first
}
// If direct parent match fails, try to find grandparent or higher ancestors
@ -52,14 +68,32 @@ final class WindowMatcher {
if let grandParentPID = processTracker.getParentProcessID(of: currentPID) {
logger.debug("Checking ancestor process PID: \(grandParentPID) at depth \(depth + 2)")
if let matchingWindow = filteredWindows.first(where: { window in
let ancestorPIDWindows = filteredWindows.filter { window in
window.ownerPID == grandParentPID
}) {
}
if ancestorPIDWindows.count == 1 {
logger
.info(
"Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)"
"Found single window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)"
)
return matchingWindow
return ancestorPIDWindows.first
} else if ancestorPIDWindows.count > 1 {
logger
.info(
"Found \(ancestorPIDWindows.count) windows for ancestor PID \(grandParentPID), checking session ID"
)
// Multiple windows - try to match by session ID in title
if let matchingWindow = ancestorPIDWindows.first(where: { window in
window.title?.contains("Session \(sessionID)") ?? false
}) {
logger.info("Found window by session ID '\(sessionID)' in title")
return matchingWindow
}
// If no session ID match, return first window
return ancestorPIDWindows.first
}
currentPID = grandParentPID
@ -210,7 +244,7 @@ final class WindowMatcher {
}
/// Find matching tab using accessibility APIs
func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? {
func findMatchingTab(tabs: [AXElement], sessionInfo: ServerSessionInfo?) -> AXElement? {
guard let sessionInfo else { return nil }
let workingDir = sessionInfo.workingDir
@ -226,10 +260,7 @@ final class WindowMatcher {
logger.debug(" Activity: \(activityStatus ?? "none")")
for (index, tab) in tabs.enumerated() {
var titleValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
let title = titleValue as? String
{
if let title = tab.title {
logger.debug("Tab \(index) title: \(title)")
// Check for session ID match first (most precise)

View file

@ -459,7 +459,7 @@ struct CustomMenuContainer<Content: View>: View {
Color.black.opacity(0.25)
}
}
private var borderColor: Color {
switch colorScheme {
case .dark:

View file

@ -92,7 +92,7 @@ struct GitRepositoryRow: View {
private var backgroundFillColor: Color {
// Show background on hover - stronger in light mode
if isHovering {
return colorScheme == .light
return colorScheme == .light
? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25)
: AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15)
}
@ -123,10 +123,10 @@ struct GitRepositoryRow: View {
Text("")
.font(.system(size: 8))
.foregroundColor(.secondary.opacity(0.5))
changeIndicators
}
Spacer()
}
.padding(.horizontal, 4)

View file

@ -29,9 +29,9 @@ struct SessionRow: View {
@State private var editedName = ""
@State private var isHoveringFolder = false
@FocusState private var isEditFieldFocused: Bool
// Computed property that reads directly from the monitor's cache
// This will automatically update when the monitor refreshes
/// Computed property that reads directly from the monitor's cache
/// This will automatically update when the monitor refreshes
private var gitRepository: GitRepository? {
gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
}
@ -108,6 +108,17 @@ struct SessionRow: View {
}
.buttonStyle(.plain)
.help("Rename session")
// Magic wand button for AI assistant sessions
if isAIAssistantSession {
Button(action: sendAIPrompt) {
Image(systemName: "wand.and.rays")
.font(.system(size: 11))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.help("Send prompt to update terminal title")
}
}
}
@ -271,14 +282,14 @@ struct SessionRow: View {
}
}
}
Divider()
Button("Copy Branch Name") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string)
}
Button("Copy Repository Path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.path, forType: .string)
@ -322,13 +333,14 @@ struct SessionRow: View {
private func getGitAppName() -> String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp) {
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
private func terminateSession() {
isTerminating = true
@ -374,6 +386,16 @@ struct SessionRow: View {
}
}
private var isAIAssistantSession: Bool {
// Check if this is an AI assistant session by looking at the command
let cmd = commandName.lowercased()
return cmd == "claude" || cmd.contains("claude") ||
cmd == "gemini" || cmd.contains("gemini") ||
cmd == "openhands" || cmd.contains("openhands") ||
cmd == "aider" || cmd.contains("aider") ||
cmd == "codex" || cmd.contains("codex")
}
private var sessionName: String {
// Use the session name if available, otherwise fall back to directory name
if let name = session.value.name, !name.isEmpty {
@ -420,6 +442,22 @@ struct SessionRow: View {
}
}
private func sendAIPrompt() {
Task {
do {
// Send a prompt that encourages the AI assistant to use vt title
let prompt = "use vt title to update the terminal title with what you're currently working on"
try await sessionService.sendInput(to: session.key, text: prompt)
// Send Enter key to submit the prompt
try await sessionService.sendKey(to: session.key, key: "enter")
} catch {
// Silently handle errors for now
print("Failed to send prompt to AI assistant: \(error)")
}
}
}
private var compactPath: String {
let path = session.value.workingDir
let homeDir = NSHomeDirectory()

View file

@ -153,10 +153,10 @@ final class StatusBarMenuManager: NSObject {
// Start monitoring git repositories for updates every 5 seconds
self?.gitRepositoryMonitor?.startMonitoring()
}
customWindow?.onHide = { [weak self] in
self?.statusBarButton?.highlight(false)
// Stop monitoring git repositories when menu closes
self?.gitRepositoryMonitor?.stopMonitoring()

View file

@ -1,7 +1,7 @@
import os
import SwiftUI
@preconcurrency import ScreenCaptureKit
import ApplicationServices
import os
@preconcurrency import ScreenCaptureKit
import SwiftUI
/// View displaying detailed information about a specific terminal session.
///
@ -85,36 +85,39 @@ struct SessionDetailView: View {
}
}
.frame(minWidth: 400)
Divider()
// Right side: Window Information and Screenshot
VStack(alignment: .leading, spacing: 20) {
Text("Window Information")
.font(.title2)
.fontWeight(.semibold)
if let windowInfo = windowInfo {
if let windowInfo {
VStack(alignment: .leading, spacing: 12) {
DetailRow(label: "Window ID", value: "\(windowInfo.windowID)")
DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName)
DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)")
if let bounds = windowInfo.bounds {
DetailRow(label: "Position", value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))")
DetailRow(
label: "Position",
value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))"
)
DetailRow(label: "Size", value: "\(Int(bounds.width)) × \(Int(bounds.height))")
}
if let title = windowInfo.title {
DetailRow(label: "Window Title", value: title)
}
HStack {
Button("Focus Window") {
focusWindow()
}
.controlSize(.regular)
Button("Capture Screenshot") {
Task {
await captureWindowScreenshot()
@ -124,14 +127,14 @@ struct SessionDetailView: View {
.disabled(isCapturingScreenshot)
}
}
// Window Screenshot
if let screenshot = windowScreenshot {
VStack(alignment: .leading, spacing: 8) {
Text("Window Preview")
.font(.headline)
.foregroundColor(.secondary)
Image(nsImage: screenshot)
.resizable()
.aspectRatio(contentMode: .fit)
@ -148,12 +151,12 @@ struct SessionDetailView: View {
Text("Screen Recording Permission Required")
.font(.headline)
.foregroundColor(.orange)
Text("VibeTunnel needs Screen Recording permission to capture window screenshots.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Open System Settings") {
openScreenRecordingSettings()
}
@ -169,16 +172,18 @@ struct SessionDetailView: View {
Label("No window found", systemImage: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.headline)
Text("Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel.")
.foregroundColor(.secondary)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
Text(
"Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel."
)
.foregroundColor(.secondary)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("No window information available")
.foregroundColor(.secondary)
}
Button(isFindingWindow ? "Searching..." : "Find Window") {
findWindow()
}
@ -187,7 +192,7 @@ struct SessionDetailView: View {
}
.padding(.vertical, 20)
}
Spacer()
}
.frame(minWidth: 400)
@ -233,39 +238,48 @@ struct SessionDetailView: View {
// TODO: Implement session termination
logger.info("Terminating session \(session.id)")
}
private func findWindow() {
isFindingWindow = true
windowSearchAttempted = true
Task { @MainActor in
defer {
isFindingWindow = false
}
logger.info("Looking for window associated with session \(session.id)")
// First, check if WindowTracker already has window info for this session
if let trackedWindow = WindowTracker.shared.windowInfo(for: session.id) {
logger.info("Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)")
logger
.info(
"Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)"
)
self.windowInfo = trackedWindow
return
}
logger.info("No tracked window found for session \(session.id), attempting to find it...")
// Get all terminal windows for debugging
let allWindows = WindowEnumerator.getAllTerminalWindows()
logger.info("Found \(allWindows.count) terminal windows currently open")
// Log details about each window for debugging
for (index, window) in allWindows.enumerated() {
logger.debug("Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "<no title>")")
logger
.debug(
"Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "<no title>")"
)
}
// Log session details for debugging
logger.info("Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)")
logger
.info(
"Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)"
)
// Try to match by various criteria
if let pid = session.pid {
logger.info("Looking for window with PID \(pid)...")
@ -284,11 +298,11 @@ struct SessionDetailView: View {
logger.warning("No window found with PID \(pid)")
}
}
// Try to find by window title containing working directory
let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent
logger.info("Looking for window with title containing '\(workingDirName)'...")
if let window = allWindows.first(where: { window in
if let title = window.title {
return title.contains(workingDirName) || title.contains(session.id)
@ -306,70 +320,76 @@ struct SessionDetailView: View {
)
return
}
logger.warning("Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows")
logger
.warning(
"Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows"
)
logger.warning("Session may not have an associated terminal window or window detection failed")
}
}
private func focusWindow() {
// Use WindowTracker's existing focus logic which handles all the complexity
logger.info("Attempting to focus window for session \(session.id)")
// First ensure we have window info
if windowInfo == nil {
logger.info("No window info cached, trying to find window first...")
findWindow()
}
if let windowInfo = windowInfo {
logger.info("Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)")
if let windowInfo {
logger
.info(
"Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)"
)
WindowTracker.shared.focusWindow(for: session.id)
} else {
logger.error("Cannot focus window - no window found for session \(session.id)")
}
}
private func captureWindowScreenshot() async {
guard let windowInfo = windowInfo else {
guard let windowInfo else {
logger.warning("No window info available for screenshot")
return
}
await MainActor.run {
isCapturingScreenshot = true
}
defer {
Task { @MainActor in
isCapturingScreenshot = false
}
}
// Check for screen recording permission
let hasPermission = await checkScreenCapturePermission()
await MainActor.run {
hasScreenCapturePermission = hasPermission
}
guard hasPermission else {
logger.warning("No screen capture permission")
return
}
do {
// Get available content
let availableContent = try await SCShareableContent.current
// Find the window
guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else {
logger.warning("Window not found in shareable content")
return
}
// Create content filter for this specific window
let filter = SCContentFilter(desktopIndependentWindow: window)
// Configure the capture
let config = SCStreamConfiguration()
config.width = Int(window.frame.width * 2) // Retina resolution
@ -377,39 +397,38 @@ struct SessionDetailView: View {
config.scalesToFit = true
config.showsCursor = false
config.captureResolution = .best
// Capture the screenshot
let screenshot = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config
)
// Convert CGImage to NSImage
let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height))
await MainActor.run {
self.windowScreenshot = nsImage
}
logger.info("Successfully captured window screenshot")
} catch {
logger.error("Failed to capture screenshot: \(error)")
}
}
private func checkScreenCapturePermission() async -> Bool {
// Check if we have screen recording permission
let hasPermission = CGPreflightScreenCaptureAccess()
if !hasPermission {
// Request permission
return CGRequestScreenCaptureAccess()
}
return true
}
private func openScreenRecordingSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)

View file

@ -111,6 +111,9 @@ struct AdvancedSettingsView: View {
.multilineTextAlignment(.center)
}
// Window Highlight section
WindowHighlightSettingsSection()
// Advanced section
Section {
VStack(alignment: .leading, spacing: 4) {
@ -345,7 +348,7 @@ private struct TerminalPreferenceSection: View {
private var gitAppBinding: Binding<String> {
Binding(
get: {
get: {
// If no preference or invalid preference, use first installed app
if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil {
return GitApp.installed.first?.rawValue ?? ""
@ -358,3 +361,180 @@ private struct TerminalPreferenceSection: View {
)
}
}
// MARK: - Window Highlight Settings Section
private struct WindowHighlightSettingsSection: View {
@AppStorage("windowHighlightEnabled")
private var highlightEnabled = true
@AppStorage("windowHighlightStyle")
private var highlightStyle = "default"
@AppStorage("windowHighlightColor")
private var highlightColorData = Data()
@State private var customColor = Color.blue
@State private var highlightEffect: WindowHighlightEffect?
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
// Enable/Disable toggle
Toggle("Show window highlight effect", isOn: $highlightEnabled)
.onChange(of: highlightEnabled) { _, newValue in
if newValue {
previewHighlightEffect()
}
}
if highlightEnabled {
// Style picker
Picker("Highlight style", selection: $highlightStyle) {
Text("Default").tag("default")
Text("Subtle").tag("subtle")
Text("Neon").tag("neon")
Text("Custom").tag("custom")
}
.pickerStyle(.segmented)
.onChange(of: highlightStyle) { _, _ in
previewHighlightEffect()
}
// Custom color picker (only shown when custom is selected)
if highlightStyle == "custom" {
HStack {
Text("Custom color")
Spacer()
ColorPicker("", selection: $customColor, supportsOpacity: false)
.labelsHidden()
.onChange(of: customColor) { _, newColor in
saveCustomColor(newColor)
previewHighlightEffect()
}
}
}
}
}
} header: {
Text("Window Highlight")
.font(.headline)
} footer: {
Text("Visual effect when focusing terminal windows to make selection more noticeable.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.onAppear {
loadCustomColor()
// Create highlight effect instance for preview
highlightEffect = WindowHighlightEffect()
}
}
private func saveCustomColor(_ color: Color) {
let nsColor = NSColor(color)
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: nsColor, requiringSecureCoding: false)
highlightColorData = data
} catch {
Logger.advanced.error("Failed to save custom color: \(error)")
}
}
private func loadCustomColor() {
if !highlightColorData.isEmpty {
do {
if let nsColor = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: highlightColorData) {
customColor = Color(nsColor)
}
} catch {
Logger.advanced.error("Failed to load custom color: \(error)")
}
}
}
private func previewHighlightEffect() {
Task { @MainActor in
// Get the current highlight configuration
let config = loadCurrentHighlightConfig()
// Update the highlight effect with new config
highlightEffect?.updateConfig(config)
// Find the settings window
guard let settingsWindow = NSApp.windows.first(where: { window in
window.title.contains("Settings") || window.title.contains("Preferences")
}) else {
Logger.advanced.debug("Could not find settings window for highlight preview")
return
}
// Get the window's accessibility element
let pid = ProcessInfo.processInfo.processIdentifier
let axApp = AXElement.application(pid: pid)
guard let windows = axApp.windows, !windows.isEmpty else {
Logger.advanced.debug("Could not get accessibility windows for highlight preview")
return
}
// Find the settings window by comparing bounds
let settingsFrame = settingsWindow.frame
var targetWindow: AXElement?
for axWindow in windows {
if let frame = axWindow.frame() {
// Check if this matches our settings window (with some tolerance for frame differences)
let tolerance: CGFloat = 5.0
if abs(frame.origin.x - settingsFrame.origin.x) < tolerance &&
abs(frame.width - settingsFrame.width) < tolerance &&
abs(frame.height - settingsFrame.height) < tolerance {
targetWindow = axWindow
break
}
}
}
// Apply highlight effect to the settings window
if let window = targetWindow {
highlightEffect?.highlightWindow(window)
} else {
Logger.advanced.debug("Could not match settings window for highlight preview")
}
}
}
private func loadCurrentHighlightConfig() -> WindowHighlightConfig {
guard highlightEnabled else {
return WindowHighlightConfig(
color: .clear,
duration: 0,
borderWidth: 0,
glowRadius: 0,
isEnabled: false
)
}
switch highlightStyle {
case "subtle":
return .subtle
case "neon":
return .neon
case "custom":
// Load custom color
let colorData = highlightColorData
if !colorData.isEmpty,
let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) {
return WindowHighlightConfig(
color: nsColor,
duration: 0.8,
borderWidth: 4.0,
glowRadius: 12.0,
isEnabled: true
)
}
return .default
default:
return .default
}
}
}

View file

@ -12,7 +12,7 @@ struct SettingsView: View {
private var debugMode = false
// MARK: - Constants
private enum Layout {
static let defaultTabSize = CGSize(width: 500, height: 620)
static let fallbackTabSize = CGSize(width: 500, height: 400)

View file

@ -37,12 +37,12 @@ struct VTCommandPageView: View {
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text("For example, to remote control Claude Code, type:")
Text("For example, to remote control AI assistants, type:")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("vt claude")
Text("vt claude or vt gemini")
.font(.system(.body, design: .monospaced))
.foregroundColor(.primary)
.padding(.horizontal, 16)

View file

@ -644,6 +644,8 @@ final class TerminalLauncher {
activate
set newWindow to (create window with default profile)
tell current session of newWindow
-- Set session name to include session ID for easier matching
set name to "Session \(sessionId)"
write text "\(config.appleScriptEscapedCommand)"
end tell
return id of newWindow

View file

@ -192,6 +192,35 @@ export class SessionView extends LitElement {
if (this.session && sessionId === this.session.id) {
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
// Check if this window should auto-close
// Only attempt to close if we're on a session-specific URL
const urlParams = new URLSearchParams(window.location.search);
const sessionParam = urlParams.get('session');
if (sessionParam === sessionId) {
// This window was opened specifically for this session
logger.log(`Session ${sessionId} exited, attempting to close window`);
// Try to close the window
// This will work for:
// 1. Windows opened via window.open() from JavaScript
// 2. Windows where the user has granted permission
// It won't work for regular browser tabs, which is fine
setTimeout(() => {
try {
window.close();
// If window.close() didn't work (we're still here after 100ms),
// show a message to the user
setTimeout(() => {
logger.log('Window close failed - likely opened as a regular tab');
}, 100);
} catch (e) {
logger.warn('Failed to close window:', e);
}
}, 500); // Give user time to see the "exited" status
}
}
},
(session: Session) => {

View file

@ -17,6 +17,7 @@ export interface StreamConnection {
eventSource: EventSource;
disconnect: () => void;
errorHandler?: EventListener;
sessionExitHandler?: EventListener;
}
export class ConnectionManager {
@ -72,6 +73,20 @@ export class ConnectionManager {
// Use CastConverter to connect terminal to stream with reconnection tracking
const connection = CastConverter.connectToStream(this.terminal, streamUrl);
// Listen for session-exit events from the terminal
const handleSessionExit = (event: Event) => {
const customEvent = event as CustomEvent;
const sessionId = customEvent.detail?.sessionId || this.session?.id;
logger.log(`Received session-exit event for session ${sessionId}`);
if (sessionId) {
this.onSessionExit(sessionId);
}
};
this.terminal.addEventListener('session-exit', handleSessionExit);
// Wrap the connection to track reconnections
const originalEventSource = connection.eventSource;
let lastErrorTime = 0;
@ -114,16 +129,23 @@ export class ConnectionManager {
// Override the error handler
originalEventSource.addEventListener('error', handleError);
// Store the connection with error handler reference
// Store the connection with error handler reference and session-exit handler
this.streamConnection = {
...connection,
errorHandler: handleError as EventListener,
sessionExitHandler: handleSessionExit as EventListener,
};
}
cleanupStreamConnection(): void {
if (this.streamConnection) {
logger.log('Cleaning up stream connection');
// Remove session-exit event listener if it exists
if (this.streamConnection.sessionExitHandler && this.terminal) {
this.terminal.removeEventListener('session-exit', this.streamConnection.sessionExitHandler);
}
this.streamConnection.disconnect();
this.streamConnection = null;
}

View file

@ -77,7 +77,7 @@ export async function startVibeTunnelForward(args: string[]) {
process.exit(0);
}
logger.log(chalk.blue(`VibeTunnel Forward v${VERSION}`) + chalk.gray(` (${BUILD_DATE})`));
logger.debug(chalk.blue(`VibeTunnel Forward v${VERSION}`) + chalk.gray(` (${BUILD_DATE})`));
logger.debug(`Full command: ${args.join(' ')}`);
// Parse command line arguments
@ -195,7 +195,7 @@ export async function startVibeTunnelForward(args: string[]) {
const sent = socketClient.updateTitle(sanitizedTitle);
if (sent) {
logger.log(`Session title updated via IPC to: ${sanitizedTitle}`);
logger.debug(`Session title updated via IPC to: ${sanitizedTitle}`);
// IPC update succeeded, server will handle the file update
socketClient.disconnect();
closeLogger();

View file

@ -438,6 +438,10 @@ export class PtyManager extends EventEmitter {
session.stdoutQueue = stdoutQueue;
}
// Create write queue for input to prevent race conditions
const inputQueue = new WriteQueue();
session.inputQueue = inputQueue;
// Setup activity detector for dynamic mode
if (session.titleMode === TitleMode.DYNAMIC) {
session.activityDetector = new ActivityDetector(session.sessionInfo.command);
@ -691,11 +695,15 @@ export class PtyManager extends EventEmitter {
switch (type) {
case MessageType.STDIN_DATA: {
const text = data as string;
if (session.ptyProcess) {
// Write input first for fastest response
session.ptyProcess.write(text);
// Then record it (non-blocking)
session.asciinemaWriter?.writeInput(text);
if (session.ptyProcess && session.inputQueue) {
// Queue input write to prevent race conditions
session.inputQueue.enqueue(() => {
if (session.ptyProcess) {
session.ptyProcess.write(text);
}
// Record it (non-blocking)
session.asciinemaWriter?.writeInput(text);
});
}
break;
}
@ -793,26 +801,31 @@ export class PtyManager extends EventEmitter {
// If we have an in-memory session with active PTY, use it
const memorySession = this.sessions.get(sessionId);
if (memorySession?.ptyProcess) {
memorySession.ptyProcess.write(dataToSend);
memorySession.asciinemaWriter?.writeInput(dataToSend);
// Track directory changes for title modes that need it
if (
(memorySession.titleMode === TitleMode.STATIC ||
memorySession.titleMode === TitleMode.DYNAMIC) &&
input.text
) {
const newDir = extractCdDirectory(
input.text,
memorySession.currentWorkingDir || memorySession.sessionInfo.workingDir
);
if (newDir) {
memorySession.currentWorkingDir = newDir;
this.markTitleUpdateNeeded(memorySession);
logger.debug(`Session ${sessionId} changed directory to: ${newDir}`);
if (memorySession?.ptyProcess && memorySession.inputQueue) {
// Queue input write to prevent race conditions
memorySession.inputQueue.enqueue(() => {
if (memorySession.ptyProcess) {
memorySession.ptyProcess.write(dataToSend);
}
}
memorySession.asciinemaWriter?.writeInput(dataToSend);
// Track directory changes for title modes that need it
if (
(memorySession.titleMode === TitleMode.STATIC ||
memorySession.titleMode === TitleMode.DYNAMIC) &&
input.text
) {
const newDir = extractCdDirectory(
input.text,
memorySession.currentWorkingDir || memorySession.sessionInfo.workingDir
);
if (newDir) {
memorySession.currentWorkingDir = newDir;
this.markTitleUpdateNeeded(memorySession);
logger.debug(`Session ${sessionId} changed directory to: ${newDir}`);
}
}
});
return; // Important: return here to avoid socket path
} else {

View file

@ -70,6 +70,7 @@ export interface PtySession {
// Optional fields for resource cleanup
inputSocketServer?: net.Server;
stdoutQueue?: WriteQueue;
inputQueue?: WriteQueue;
// Terminal title mode
titleMode?: TitleMode;
// Track current working directory for title updates