Fix notification focus/read handling and add split surface control

This commit is contained in:
Lawrence Chen 2026-01-22 18:58:04 -08:00
parent bc074d20c1
commit 5acb4e47b1
6 changed files with 384 additions and 17 deletions

View file

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

View file

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

View file

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

View file

@ -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 <direction> - Split focused surface (left/right/up/down)
list_surfaces [tab] - List surfaces for tab (current tab if omitted)
focus_surface <id|idx> - Focus surface by ID or index (current tab)
close_tab <id> - Close tab by ID
select_tab <id|index> - Select tab by ID or index (0-based)
current_tab - Get current tab ID
send <text> - Send text to current tab
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
send_surface <id|idx> <text> - Send text to a surface in current tab
send_key_surface <id|idx> <key> - 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<TerminalSurface>.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 <id|idx> <text>" }
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 <id|idx> <key>" }
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()
}

View file

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

View file

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