Add notifications and clipboard context menu

This commit is contained in:
Lawrence Chen 2026-01-22 16:53:03 -08:00
parent f7c421c56a
commit 62136dbdd3
8 changed files with 754 additions and 24 deletions

View file

@ -14,6 +14,9 @@
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; };
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -40,6 +43,9 @@
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* GhosttyTabs-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "GhosttyTabs-Bridging-Header.h"; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; };
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -74,6 +80,9 @@
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
A5001019 /* TerminalController.swift */,
A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */,
);
path = Sources;
sourceTree = "<group>";
@ -145,6 +154,9 @@
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

111
Sources/AppDelegate.swift Normal file
View file

@ -0,0 +1,111 @@
import AppKit
import UserNotifications
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
static var shared: AppDelegate?
weak var tabManager: TabManager?
weak var notificationStore: TerminalNotificationStore?
override init() {
super.init()
Self.shared = self
}
func applicationDidFinishLaunching(_ notification: Notification) {
configureUserNotifications()
}
func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore) {
self.tabManager = tabManager
self.notificationStore = notificationStore
}
private func configureUserNotifications() {
let actions = [
UNNotificationAction(
identifier: TerminalNotificationStore.actionShowIdentifier,
title: "Show"
)
]
let category = UNNotificationCategory(
identifier: TerminalNotificationStore.categoryIdentifier,
actions: actions,
intentIdentifiers: [],
options: [.customDismissAction]
)
let center = UNUserNotificationCenter.current()
center.setNotificationCategories([category])
center.delegate = self
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
handleNotificationResponse(response)
completionHandler()
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let shouldPresent = shouldPresentNotification(notification)
let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : []
completionHandler(options)
}
private func handleNotificationResponse(_ response: UNNotificationResponse) {
guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String,
let tabId = UUID(uuidString: tabIdString) else {
return
}
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)
}
case UNNotificationDismissActionIdentifier:
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)
}
}
default:
break
}
}
private func shouldPresentNotification(_ notification: UNNotification) -> Bool {
guard let tabManager else { return true }
guard let tabIdString = notification.request.content.userInfo["tabId"] as? String,
let tabId = UUID(uuidString: tabIdString) else {
return true
}
let isAppActive = NSApp.isActive
let isTabActive = tabManager.selectedTabId == tabId
let isKeyWindow = NSApp.keyWindow?.isKeyWindow ?? false
if isAppActive && isTabActive && isKeyWindow {
return false
}
return true
}
}

View file

@ -1,31 +1,60 @@
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@State private var sidebarWidth: CGFloat = 200
@State private var sidebarDragStart: CGFloat?
@FocusState private var focusedTabId: UUID?
@State private var sidebarSelection: SidebarSelection = .tabs
var body: some View {
HStack(spacing: 0) {
// Vertical Tabs Sidebar
VerticalTabsSidebar(sidebarWidth: sidebarWidth)
VerticalTabsSidebar(
sidebarWidth: sidebarWidth,
selection: $sidebarSelection
)
.frame(width: sidebarWidth)
// Divider
Rectangle()
.fill(Color(nsColor: .separatorColor))
.frame(width: 1)
.contentShape(Rectangle())
.gesture(
DragGesture()
.onChanged { value in
if sidebarDragStart == nil {
sidebarDragStart = sidebarWidth
}
let base = sidebarDragStart ?? sidebarWidth
sidebarWidth = max(140, min(360, base + value.translation.width))
}
.onEnded { _ in
sidebarDragStart = nil
}
)
// Terminal Content - use ZStack to keep all surfaces alive
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.focusable()
.focused($focusedTabId, equals: tab.id)
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.focusable()
.focused($focusedTabId, equals: tab.id)
}
}
.opacity(sidebarSelection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelection == .tabs)
NotificationsPage(selection: $sidebarSelection)
.opacity(sidebarSelection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelection == .notifications)
}
}
.frame(minWidth: 800, minHeight: 600)
@ -35,22 +64,57 @@ 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
}
}
}
struct VerticalTabsSidebar: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
let sidebarWidth: CGFloat
@Binding var selection: SidebarSelection
var body: some View {
VStack(spacing: 0) {
// Header with title
HStack {
Text("Tabs")
.font(.headline)
.foregroundColor(.secondary)
Button(action: { selection = .tabs }) {
Text("Tabs")
.font(.headline)
.foregroundColor(selection == .tabs ? .primary : .secondary)
}
.buttonStyle(.plain)
Spacer()
Button(action: { selection = .notifications }) {
HStack(spacing: 6) {
Image(systemName: "bell")
.font(.system(size: 12, weight: .medium))
if notificationStore.unreadCount > 0 {
Text("\(notificationStore.unreadCount)")
.font(.system(size: 10, weight: .semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
}
}
}
.buttonStyle(.plain)
.foregroundColor(selection == .notifications ? .primary : .secondary)
Button(action: { tabManager.addTab() }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
@ -67,7 +131,7 @@ struct VerticalTabsSidebar: View {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(tabManager.tabs) { tab in
TabItemView(tab: tab)
TabItemView(tab: tab, selection: $selection)
}
}
.padding(.vertical, 4)
@ -82,6 +146,7 @@ struct VerticalTabsSidebar: View {
struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager
@ObservedObject var tab: Tab
@Binding var selection: SidebarSelection
@State private var isHovering = false
var isSelected: Bool {
@ -122,9 +187,15 @@ struct TabItemView: View {
.contentShape(Rectangle())
.onTapGesture {
tabManager.selectTab(tab)
selection = .tabs
}
.onHover { hovering in
isHovering = hovering
}
}
}
enum SidebarSelection {
case tabs
case notifications
}

View file

@ -3,6 +3,8 @@ import SwiftUI
@main
struct GhosttyTabsApp: App {
@StateObject private var tabManager = TabManager()
@StateObject private var notificationStore = TerminalNotificationStore.shared
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
// Start the terminal controller for programmatic control
@ -13,9 +15,11 @@ struct GhosttyTabsApp: App {
WindowGroup {
ContentView()
.environmentObject(tabManager)
.environmentObject(notificationStore)
.onAppear {
// Start the Unix socket controller for programmatic access
TerminalController.shared.start(tabManager: tabManager)
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore)
}
}
.windowStyle(.hiddenTitleBar)

View file

@ -3,6 +3,59 @@ import AppKit
import Metal
import QuartzCore
private enum GhosttyPasteboardHelper {
private static let selectionPasteboard = NSPasteboard(
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
)
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
switch location {
case GHOSTTY_CLIPBOARD_STANDARD:
return .general
case GHOSTTY_CLIPBOARD_SELECTION:
return selectionPasteboard
default:
return nil
}
}
static func stringContents(from pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
!urls.isEmpty {
return urls
.map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString }
.joined(separator: " ")
}
if let value = pasteboard.string(forType: .string) {
return value
}
return pasteboard.string(forType: utf8PlainTextType)
}
static func hasString(for location: ghostty_clipboard_e) -> Bool {
guard let pasteboard = pasteboard(for: location) else { return false }
return (stringContents(from: pasteboard) ?? "").isEmpty == false
}
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
guard let pasteboard = pasteboard(for: location) else { return }
pasteboard.clearContents()
pasteboard.setString(string, forType: .string)
}
private static func escapeForShell(_ value: String) -> String {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
}
}
// Minimal Ghostty wrapper for terminal rendering
// This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation
@ -52,17 +105,49 @@ class GhosttyApp {
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
guard let userdata else { return }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
guard let surface = surfaceView.terminalSurface?.surface else { return }
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
value.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
}
}
runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
guard let userdata, let content else { return }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
guard let surface = surfaceView.terminalSurface?.surface else { return }
ghostty_surface_complete_clipboard_request(surface, content, state, true)
}
runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in
// Write clipboard
if let content = content {
let data = Data(bytes: content, count: Int(len))
if let string = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
guard let content = content, len > 0 else { return }
let buffer = UnsafeBufferPointer(start: content, count: Int(len))
var fallback: String?
for item in buffer {
guard let dataPtr = item.data else { continue }
let value = String(cString: dataPtr)
if let mimePtr = item.mime {
let mime = String(cString: mimePtr)
if mime.hasPrefix("text/plain") {
GhosttyPasteboardHelper.writeString(value, to: location)
return
}
}
if fallback == nil {
fallback = value
}
}
if let fallback {
GhosttyPasteboardHelper.writeString(fallback, to: location)
}
}
runtimeConfig.close_surface_cb = { userdata, processAlive in
@ -107,7 +192,23 @@ class GhosttyApp {
}
private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool {
guard target.tag == GHOSTTY_TARGET_SURFACE else { return false }
if target.tag != GHOSTTY_TARGET_SURFACE {
if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
let tabId = AppDelegate.shared?.tabManager?.selectedTabId {
let actionTitle = action.action.desktop_notification.title
.flatMap { String(cString: $0) } ?? ""
let actionBody = action.action.desktop_notification.body
.flatMap { String(cString: $0) } ?? ""
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
let body = actionBody.isEmpty ? actionTitle : actionBody
DispatchQueue.main.async {
TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body)
}
return true
}
return false
}
guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false }
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
@ -133,6 +234,34 @@ class GhosttyApp {
userInfo: [GhosttyNotificationKey.cellSize: cellSize]
)
return true
case GHOSTTY_ACTION_SET_TITLE:
let title = action.action.set_title.title
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .ghosttyDidSetTitle,
object: surfaceView,
userInfo: [
GhosttyNotificationKey.tabId: tabId,
GhosttyNotificationKey.title: title,
]
)
}
}
return true
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
guard let tabId = surfaceView.tabId else { return true }
let actionTitle = action.action.desktop_notification.title
.flatMap { String(cString: $0) } ?? ""
let actionBody = action.action.desktop_notification.body
.flatMap { String(cString: $0) } ?? ""
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
let body = actionBody.isEmpty ? actionTitle : actionBody
DispatchQueue.main.async {
TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body)
}
return true
default:
return false
}
@ -145,8 +274,10 @@ class TerminalSurface {
private(set) var surface: ghostty_surface_t?
private var displayLink: CVDisplayLink?
private weak var attachedView: GhosttyNSView?
let tabId: UUID
init() {
init(tabId: UUID) {
self.tabId = tabId
// Surface is created when attached to a view
}
@ -276,12 +407,13 @@ class TerminalSurface {
// MARK: - Ghostty Surface View
class GhosttyNSView: NSView {
class GhosttyNSView: NSView, NSUserInterfaceValidations {
var terminalSurface: TerminalSurface?
private var surfaceAttached = false
var scrollbar: GhosttyScrollbar?
var cellSize: CGSize = .zero
var desiredFocus: Bool = false
var tabId: UUID?
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
@ -345,6 +477,7 @@ class GhosttyNSView: NSView {
func attachSurface(_ surface: TerminalSurface) {
terminalSurface = surface
tabId = surface.tabId
surfaceAttached = false
attachSurfaceIfNeeded()
}
@ -399,6 +532,30 @@ class GhosttyNSView: NSView {
// MARK: - Input Handling
@IBAction func copy(_ sender: Any?) {
_ = performBindingAction("copy_to_clipboard")
}
@IBAction func paste(_ sender: Any?) {
_ = performBindingAction("paste_from_clipboard")
}
@IBAction func pasteAsPlainText(_ sender: Any?) {
_ = performBindingAction("paste_from_clipboard")
}
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
switch item.action {
case #selector(copy(_:)):
guard let surface = surface else { return false }
return ghostty_surface_has_selection(surface)
case #selector(paste(_:)), #selector(pasteAsPlainText(_:)):
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
default:
return true
}
}
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool {
@ -580,6 +737,50 @@ class GhosttyNSView: NSView {
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
override func rightMouseDown(with event: NSEvent) {
guard let surface = surface else { return }
if !ghostty_surface_mouse_captured(surface) {
super.rightMouseDown(with: event)
return
}
window?.makeFirstResponder(self)
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
}
override func rightMouseUp(with event: NSEvent) {
guard let surface = surface else { return }
if !ghostty_surface_mouse_captured(surface) {
super.rightMouseUp(with: event)
return
}
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
}
override func menu(for event: NSEvent) -> NSMenu? {
guard let surface = surface else { return nil }
if ghostty_surface_mouse_captured(surface) {
return nil
}
window?.makeFirstResponder(self)
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
let menu = NSMenu()
if ghostty_surface_has_selection(surface) {
let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
item.target = self
}
let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "")
pasteItem.target = self
return menu
}
override func mouseMoved(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@ -672,6 +873,8 @@ struct GhosttyScrollbar {
enum GhosttyNotificationKey {
static let scrollbar = "ghostty.scrollbar"
static let cellSize = "ghostty.cellSize"
static let tabId = "ghostty.tabId"
static let title = "ghostty.title"
}
extension Notification.Name {

View file

@ -0,0 +1,137 @@
import SwiftUI
struct NotificationsPage: View {
@EnvironmentObject var notificationStore: TerminalNotificationStore
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
var body: some View {
VStack(spacing: 0) {
header
Divider()
if notificationStore.notifications.isEmpty {
emptyState
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(notificationStore.notifications) { notification in
NotificationRow(
notification: notification,
tabTitle: tabTitle(for: notification.tabId),
onOpen: {
tabManager.focusTab(notification.tabId)
notificationStore.markRead(id: notification.id)
selection = .tabs
},
onClear: {
notificationStore.remove(id: notification.id)
}
)
}
}
.padding(16)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private var header: some View {
HStack {
Text("Notifications")
.font(.title2)
.fontWeight(.semibold)
Spacer()
if !notificationStore.notifications.isEmpty {
Button("Clear All") {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "bell.slash")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No notifications yet")
.font(.headline)
Text("Desktop notifications will appear here for quick review.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func tabTitle(for tabId: UUID) -> String? {
tabManager.tabs.first(where: { $0.id == tabId })?.title
}
}
private struct NotificationRow: View {
let notification: TerminalNotification
let tabTitle: String?
let onOpen: () -> Void
let onClear: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor)
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(notification.title)
.font(.headline)
.foregroundColor(.primary)
Spacer()
Text(notification.createdAt, style: .time)
.font(.caption)
.foregroundColor(.secondary)
}
if !notification.body.isEmpty {
Text(notification.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(3)
}
if let tabTitle {
Text(tabTitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlBackgroundColor))
)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
}

View file

@ -1,31 +1,49 @@
import AppKit
import SwiftUI
import Foundation
class Tab: Identifiable, ObservableObject {
let id = UUID()
let id: UUID
@Published var title: String
@Published var currentDirectory: String
let terminalSurface: TerminalSurface
init(title: String = "Terminal") {
self.id = UUID()
self.title = title
self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path
self.terminalSurface = TerminalSurface()
self.terminalSurface = TerminalSurface(tabId: id)
}
}
class TabManager: ObservableObject {
@Published var tabs: [Tab] = []
@Published var selectedTabId: UUID?
private var observers: [NSObjectProtocol] = []
init() {
addTab()
observers.append(NotificationCenter.default.addObserver(
forName: .ghosttyDidSetTitle,
object: nil,
queue: .main
) { [weak self] notification in
guard let self else { return }
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return }
self.updateTabTitle(tabId: tabId, title: title)
})
}
func addTab() {
let newTab = Tab(title: "Terminal \(tabs.count + 1)")
tabs.append(newTab)
selectedTabId = newTab.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: newTab.id]
)
}
func closeTab(_ tab: Tab) {
@ -54,6 +72,36 @@ class TabManager: ObservableObject {
selectedTabId = tab.id
}
func titleForTab(_ tabId: UUID) -> String? {
tabs.first(where: { $0.id == tabId })?.title
}
private func updateTabTitle(tabId: UUID, title: String) {
guard !title.isEmpty else { return }
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
if tabs[index].title != title {
tabs[index].title = title
}
}
func focusTab(_ tabId: UUID) {
guard tabs.contains(where: { $0.id == tabId }) else { return }
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
}
func selectNextTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
@ -73,3 +121,8 @@ class TabManager: ObservableObject {
selectedTabId = tabs[index].id
}
}
extension Notification.Name {
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
}

View file

@ -0,0 +1,139 @@
import AppKit
import Foundation
import UserNotifications
struct TerminalNotification: Identifiable, Hashable {
let id: UUID
let tabId: UUID
let title: String
let body: String
let createdAt: Date
var isRead: Bool
}
final class TerminalNotificationStore: ObservableObject {
static let shared = TerminalNotificationStore()
static let categoryIdentifier = "com.cmux.ghosttytabs.userNotification"
static let actionShowIdentifier = "com.cmux.ghosttytabs.userNotification.show"
@Published private(set) var notifications: [TerminalNotification] = []
private let center = UNUserNotificationCenter.current()
private var hasRequestedAuthorization = false
private init() {}
var unreadCount: Int {
notifications.filter { !$0.isRead }.count
}
func addNotification(tabId: UUID, title: String, body: String) {
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab
let notification = TerminalNotification(
id: UUID(),
tabId: tabId,
title: title,
body: body,
createdAt: Date(),
isRead: shouldMarkRead
)
notifications.insert(notification, at: 0)
if !shouldMarkRead {
scheduleUserNotification(notification)
}
}
func markRead(id: UUID) {
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
if notifications[index].isRead { return }
notifications[index].isRead = true
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
}
func markRead(forTabId tabId: UUID) {
var idsToClear: [String] = []
for index in notifications.indices {
if notifications[index].tabId == tabId && !notifications[index].isRead {
notifications[index].isRead = true
idsToClear.append(notifications[index].id.uuidString)
}
}
if !idsToClear.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
}
}
func remove(id: UUID) {
notifications.removeAll { $0.id == id }
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
}
func clearAll() {
let ids = notifications.map { $0.id.uuidString }
notifications.removeAll()
if !ids.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: ids)
}
}
private func scheduleUserNotification(_ notification: TerminalNotification) {
ensureAuthorization { [weak self] authorized in
guard let self, authorized else { return }
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = UNNotificationSound.default
content.categoryIdentifier = Self.categoryIdentifier
content.userInfo = [
"tabId": notification.tabId.uuidString,
"notificationId": notification.id.uuidString,
]
let request = UNNotificationRequest(
identifier: notification.id.uuidString,
content: content,
trigger: nil
)
self.center.add(request) { error in
if let error {
NSLog("Failed to schedule notification: \(error)")
}
}
}
}
private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) {
center.getNotificationSettings { [weak self] settings in
guard let self else {
completion(false)
return
}
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
completion(true)
case .denied:
completion(false)
case .notDetermined:
self.requestAuthorizationIfNeeded(completion)
@unknown default:
completion(false)
}
}
}
private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) {
guard !hasRequestedAuthorization else {
completion(false)
return
}
hasRequestedAuthorization = true
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
completion(granted)
}
}
}