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 # 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

View file

@ -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

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 /// 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],

View file

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

View file

@ -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."
)
} }
} }
} }

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)) { 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)

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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) => {

View file

@ -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;
} }

View file

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

View file

@ -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 {

View file

@ -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