diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index db0987a0..09d676fb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import CoreServices import UserNotifications final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { @@ -6,6 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? + private var workspaceObserver: NSObjectProtocol? override init() { super.init() @@ -13,6 +15,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationDidFinishLaunching(_ notification: Notification) { + registerLaunchServicesBundle() + enforceSingleInstance() + observeDuplicateLaunches() configureUserNotifications() } @@ -41,6 +46,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent center.delegate = self } + private func registerLaunchServicesBundle() { + let bundleURL = Bundle.main.bundleURL.standardizedFileURL + let registerStatus = LSRegisterURL(bundleURL as CFURL, true) + if registerStatus != noErr { + NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(bundleURL.path)") + } + } + + private func enforceSingleInstance() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier + let currentURL = Bundle.main.bundleURL.standardizedFileURL + + for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { + guard app.processIdentifier != currentPid else { continue } + if let url = app.bundleURL?.standardizedFileURL, url == currentURL { continue } + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + } + } + + private func observeDuplicateLaunches() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier + let currentURL = Bundle.main.bundleURL.standardizedFileURL + + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard self != nil else { return } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let url = app.bundleURL?.standardizedFileURL, url == currentURL { return } + + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + } + func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -55,7 +106,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - completionHandler([.banner, .sound]) + completionHandler([.banner, .sound, .list]) } private func handleNotificationResponse(_ response: UNNotificationResponse) { @@ -73,13 +124,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: DispatchQueue.main.async { - if let notificationId = UUID(uuidString: response.notification.request.identifier) { - self.notificationStore?.markRead(id: notificationId) - } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, - let notificationId = UUID(uuidString: notificationIdString) { - self.notificationStore?.markRead(id: notificationId) - } self.tabManager?.focusTab(tabId, surfaceId: surfaceId) + self.markReadIfFocused(response: response, tabId: tabId, surfaceId: surfaceId) } case UNNotificationDismissActionIdentifier: DispatchQueue.main.async { @@ -95,4 +141,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func markReadIfFocused(response: UNNotificationResponse, tabId: UUID, surfaceId: UUID?) { + let notificationId: UUID? = { + if let id = UUID(uuidString: response.notification.request.identifier) { + return id + } + if let idString = response.notification.request.content.userInfo["notificationId"] as? String, + let id = UUID(uuidString: idString) { + return id + } + return nil + }() + + guard let notificationId else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard let tabManager = self.tabManager else { return } + guard tabManager.selectedTabId == tabId else { return } + if let surfaceId { + guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } + } + self.notificationStore?.markRead(id: notificationId) + } + } + } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 51a22f29..5e33da6d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -64,14 +64,6 @@ struct ContentView: View { } .onChange(of: tabManager.selectedTabId) { newValue in focusedTabId = newValue - if let newValue { - notificationStore.markRead(forTabId: newValue) - } - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - if let selected = tabManager.selectedTabId { - notificationStore.markRead(forTabId: selected) - } } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in sidebarSelection = .tabs diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 9b20da50..43e91dbf 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -21,7 +21,7 @@ struct NotificationsPage: View { tabTitle: tabTitle(for: notification.tabId), onOpen: { tabManager.focusTab(notification.tabId, surfaceId: notification.surfaceId) - notificationStore.markRead(id: notification.id) + markReadIfFocused(notification) selection = .tabs }, onClear: { @@ -74,6 +74,16 @@ struct NotificationsPage: View { private func tabTitle(for tabId: UUID) -> String? { tabManager.tabs.first(where: { $0.id == tabId })?.title } + + private func markReadIfFocused(_ notification: TerminalNotification) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard tabManager.selectedTabId == notification.tabId else { return } + if let surfaceId = notification.surfaceId { + guard tabManager.focusedSurfaceId(for: notification.tabId) == surfaceId else { return } + } + notificationStore.markRead(id: notification.id) + } + } } private struct NotificationRow: View { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bcc02025..bcf1a4d8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -137,6 +137,15 @@ class TerminalController { case "new_tab": return newTab() + case "new_split": + return newSplit(args) + + case "list_surfaces": + return listSurfaces(args) + + case "focus_surface": + return focusSurface(args) + case "close_tab": return closeTab(args) @@ -152,6 +161,12 @@ class TerminalController { case "send_key": return sendKey(args) + case "send_surface": + return sendInputToSurface(args) + + case "send_key_surface": + return sendKeyToSurface(args) + case "help": return helpText() @@ -166,11 +181,16 @@ class TerminalController { ping - Check if server is running list_tabs - List all tabs with IDs new_tab - Create a new tab + new_split - Split focused surface (left/right/up/down) + list_surfaces [tab] - List surfaces for tab (current tab if omitted) + focus_surface - Focus surface by ID or index (current tab) close_tab - Close tab by ID select_tab - Select tab by ID or index (0-based) current_tab - Get current tab ID send - Send text to current tab send_key - Send special key (ctrl-c, ctrl-d, enter, tab, escape) + send_surface - Send text to a surface in current tab + send_key_surface - Send special key to a surface in current tab help - Show this help """ } @@ -200,6 +220,128 @@ class TerminalController { return "OK \(newTabId?.uuidString ?? "unknown")" } + private func newSplit(_ directionArg: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + let trimmed = directionArg.trimmingCharacters(in: .whitespacesAndNewlines) + guard let direction = parseSplitDirection(trimmed) else { + return "ERROR: Invalid direction. Use left, right, up, or down." + } + + var success = false + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }), + let surfaceId = tab.focusedSurfaceId else { + return + } + success = tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) + } + return success ? "OK" : "ERROR: Failed to create split" + } + + private func listSurfaces(_ tabArg: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + var result = "" + DispatchQueue.main.sync { + guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else { + result = "ERROR: Tab not found" + return + } + let surfaces = tab.splitTree.root?.leaves() ?? [] + let focusedId = tab.focusedSurfaceId + let lines = surfaces.enumerated().map { index, surface in + let selected = surface.id == focusedId ? "*" : " " + return "\(selected) \(index): \(surface.id.uuidString)" + } + result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n") + } + return result + } + + private func focusSurface(_ arg: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" } + + var success = false + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return + } + + if let uuid = UUID(uuidString: trimmed), + tab.surface(for: uuid) != nil { + tabManager.focusSurface(tabId: tab.id, surfaceId: uuid) + success = true + return + } + + if let index = Int(trimmed), index >= 0 { + let surfaces = tab.splitTree.root?.leaves() ?? [] + guard index < surfaces.count else { return } + tabManager.focusSurface(tabId: tab.id, surfaceId: surfaces[index].id) + success = true + } + } + + return success ? "OK" : "ERROR: Surface not found" + } + + private func parseSplitDirection(_ value: String) -> SplitTree.NewDirection? { + switch value.lowercased() { + case "left", "l": + return .left + case "right", "r": + return .right + case "up", "u": + return .up + case "down", "d": + return .down + default: + return nil + } + } + + private func resolveTab(from arg: String, tabManager: TabManager) -> Tab? { + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + guard let selected = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == selected }) + } + + if let uuid = UUID(uuidString: trimmed) { + return tabManager.tabs.first(where: { $0.id == uuid }) + } + + if let index = Int(trimmed), index >= 0, index < tabManager.tabs.count { + return tabManager.tabs[index] + } + + return nil + } + + private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil + } + + if let uuid = UUID(uuidString: arg), + let surface = tab.surface(for: uuid)?.surface { + return surface + } + + if let index = Int(arg), index >= 0 { + let surfaces = tab.splitTree.root?.leaves() ?? [] + guard index < surfaces.count else { return nil } + return surfaces[index].surface + } + + return nil + } + private func closeTab(_ tabId: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" } @@ -283,6 +425,41 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendInputToSurface(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + let parts = args.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return "ERROR: Usage: send_surface " } + + let target = parts[0] + let text = parts[1] + + var success = false + DispatchQueue.main.sync { + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } + + let unescaped = text + .replacingOccurrences(of: "\\n", with: "\r") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") + + for char in unescaped { + String(char).withCString { ptr in + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = ptr + keyEvent.composing = false + _ = ghostty_surface_key(surface, keyEvent) + } + } + success = true + } + + return success ? "OK" : "ERROR: Failed to send input" + } + private func sendKey(_ keyName: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } @@ -364,6 +541,72 @@ class TerminalController { return success ? "OK" : "ERROR: Unknown key '\(keyName)'" } + private func sendKeyToSurface(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + let parts = args.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return "ERROR: Usage: send_key_surface " } + + let target = parts[0] + let keyName = parts[1] + + var success = false + DispatchQueue.main.sync { + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } + + func sendKeyEvent(text: String, mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE) { + text.withCString { ptr in + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = mods + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = ptr + keyEvent.composing = false + _ = ghostty_surface_key(surface, keyEvent) + } + } + + switch keyName.lowercased() { + case "ctrl-c", "ctrl+c", "sigint": + sendKeyEvent(text: "\u{03}") + success = true + case "ctrl-d", "ctrl+d", "eof": + sendKeyEvent(text: "\u{04}") + success = true + case "ctrl-z", "ctrl+z", "sigtstp": + sendKeyEvent(text: "\u{1A}") + success = true + case "ctrl-\\", "ctrl+\\", "sigquit": + sendKeyEvent(text: "\u{1C}") + success = true + case "enter", "return": + sendKeyEvent(text: "\r") + success = true + case "tab": + sendKeyEvent(text: "\t") + success = true + case "escape", "esc": + sendKeyEvent(text: "\u{1B}") + success = true + case "backspace": + sendKeyEvent(text: "\u{7F}") + success = true + default: + if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") { + let letter = keyName.dropFirst(5).lowercased() + if letter.count == 1, let char = letter.first, char.isLetter { + let ctrlCode = UInt8(char.asciiValue! - Character("a").asciiValue! + 1) + let ctrlChar = String(UnicodeScalar(ctrlCode)) + sendKeyEvent(text: ctrlChar) + success = true + } + } + } + } + + return success ? "OK" : "ERROR: Failed to send key" + } + deinit { stop() } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 73812960..35c291fb 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -31,7 +31,9 @@ final class TerminalNotificationStore: ObservableObject { func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) { let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId - let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab + let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId) + let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId + let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab && isFocusedSurface let notification = TerminalNotification( id: UUID(), tabId: tabId, diff --git a/tests/ghosttytabs.py b/tests/ghosttytabs.py index 017f73ea..8cbae208 100755 --- a/tests/ghosttytabs.py +++ b/tests/ghosttytabs.py @@ -21,6 +21,9 @@ Usage: client.new_tab() client.list_tabs() client.select_tab(0) + client.new_split("right") + client.list_surfaces() + client.focus_surface(0) client.close() """ @@ -125,6 +128,12 @@ class GhosttyTabs: return response[3:] raise GhosttyTabsError(response) + def new_split(self, direction: str) -> None: + """Create a split in the given direction (left/right/up/down).""" + response = self._send_command(f"new_split {direction}") + if not response.startswith("OK"): + raise GhosttyTabsError(response) + def close_tab(self, tab_id: str) -> None: """Close a tab by ID""" response = self._send_command(f"close_tab {tab_id}") @@ -137,6 +146,34 @@ class GhosttyTabs: if not response.startswith("OK"): raise GhosttyTabsError(response) + def list_surfaces(self, tab: str | int | None = None) -> List[Tuple[int, str, bool]]: + """ + List surfaces for a tab. Returns list of (index, id, is_focused) tuples. + If tab is None, uses the current tab. + """ + arg = "" if tab is None else str(tab) + response = self._send_command(f"list_surfaces {arg}".rstrip()) + if response in ("No surfaces", "ERROR: Tab not found"): + return [] + + surfaces = [] + for line in response.split("\n"): + if not line.strip(): + continue + selected = line.startswith("*") + parts = line.lstrip("* ").split(" ", 1) + if len(parts) >= 2: + index = int(parts[0].rstrip(":")) + surface_id = parts[1] + surfaces.append((index, surface_id, selected)) + return surfaces + + def focus_surface(self, surface: str | int) -> None: + """Focus a surface by ID or index in the current tab.""" + response = self._send_command(f"focus_surface {surface}") + if not response.startswith("OK"): + raise GhosttyTabsError(response) + def current_tab(self) -> str: """Get the current tab's ID""" response = self._send_command("current_tab") @@ -160,6 +197,13 @@ class GhosttyTabs: if not response.startswith("OK"): raise GhosttyTabsError(response) + def send_surface(self, surface: str | int, text: str) -> None: + """Send text to a specific surface by ID or index in the current tab.""" + escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + response = self._send_command(f"send_surface {surface} {escaped}") + if not response.startswith("OK"): + raise GhosttyTabsError(response) + def send_key(self, key: str) -> None: """ Send a special key to the current terminal. @@ -173,6 +217,12 @@ class GhosttyTabs: if not response.startswith("OK"): raise GhosttyTabsError(response) + def send_key_surface(self, surface: str | int, key: str) -> None: + """Send a special key to a specific surface by ID or index in the current tab.""" + response = self._send_command(f"send_key_surface {surface} {key}") + if not response.startswith("OK"): + raise GhosttyTabsError(response) + def send_line(self, text: str) -> None: """Send text followed by Enter""" self.send(text + "\\n")